Update from Google.

--
MOE_MIGRATED_REVID=85702957
diff --git a/src/test/java/BUILD b/src/test/java/BUILD
new file mode 100644
index 0000000..03b1481
--- /dev/null
+++ b/src/test/java/BUILD
@@ -0,0 +1,168 @@
+java_library(
+    name = "testutil",
+    srcs = glob(["com/google/devtools/build/lib/testutil/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//src/main/java:bazel-core",
+        "//third_party:guava",
+        "//third_party:guava-testlib",
+        "//third_party:junit4",
+        "//third_party:truth",
+    ],
+)
+
+java_test(
+    name = "skyframe_test",
+    srcs = glob([
+        "com/google/devtools/build/skyframe/*.java",
+    ]),
+    args = ["com.google.devtools.build.skyframe.AllTests"],
+    deps = [
+        ":testutil",
+        "//src/main/java:bazel-core",
+        "//third_party:guava",
+        "//third_party:guava-testlib",
+        "//third_party:jsr305",
+        "//third_party:junit4",
+        "//third_party:truth",
+    ],
+)
+
+java_test(
+    name = "options_test",
+    srcs = glob([
+        "com/google/devtools/common/options/*.java",
+    ]),
+    args = ["com.google.devtools.common.options.AllTests"],
+    deps = [
+        ":testutil",
+        "//src/main/java:bazel-core",
+        "//third_party:guava",
+        "//third_party:guava-testlib",
+        "//third_party:junit4",
+        "//third_party:truth",
+    ],
+)
+
+test_prefix = "com/google/devtools/build/lib"
+
+java_library(
+    name = "foundations_testutil",
+    srcs = glob([
+        "%s/vfs/util/*.java" % test_prefix,
+    ]),
+    deps = [
+        ":testutil",
+        "//src/main/java:bazel-core",
+        "//src/main/java:shell",
+        "//third_party:guava",
+        "//third_party:guava-testlib",
+        "//third_party:junit4",
+        "//third_party:truth",
+    ],
+)
+
+java_library(
+    name = "test_runner",
+    srcs = [test_prefix + "/AllTests.java"],
+    deps = [
+        ":testutil",
+        "//third_party:junit4",
+    ],
+)
+
+java_test(
+    name = "foundations_test",
+    srcs = glob(
+        ["%s/%s" % (test_prefix, p) for p in [
+            "concurrent/*.java",
+            "collect/*.java",
+            "collect/nestedset/*.java",
+            "events/*.java",
+            "testutiltests/*.java",
+            "unix/*.java",
+            "util/*.java",
+            "util/io/*.java",
+            "vfs/*.java",
+            "vfs/inmemoryfs/*.java",
+        ]],
+        # java_rules_oss doesn't support resource loading with
+        # qualified paths.
+        exclude = [
+            test_prefix + f
+            for f in [
+                "/util/DependencySetWindowsTest.java",
+                "/util/ResourceFileLoaderTest.java",
+                "/vfs/PathFragmentWindowsTest.java",
+                "/vfs/PathWindowsTest.java",
+            ]
+        ],
+    ),
+    args = ["com.google.devtools.build.lib.AllTests"],
+    data = glob([test_prefix + "/vfs/*.zip"]) + [
+        "//src/main/native:libunix.dylib",
+        "//src/main/native:libunix.so",
+    ],
+    deps = [
+        ":foundations_testutil",
+        ":test_runner",
+        ":testutil",
+        "//src/main/java:bazel-core",
+        "//src/main/java:shell",
+        "//third_party:guava",
+        "//third_party:guava-testlib",
+        "//third_party:junit4",
+        "//third_party:truth",
+    ],
+)
+
+java_test(
+    name = "windows_test",
+    srcs = glob(["%s/%s" % (test_prefix, p) for p in [
+        "util/DependencySetWindowsTest.java",
+        "vfs/PathFragmentWindowsTest.java",
+        "vfs/PathWindowsTest.java",
+    ]]),
+    args = [
+        "com.google.devtools.build.lib.AllTests",
+    ],
+    data = [
+        "//src/main/native:libunix.dylib",
+        "//src/main/native:libunix.so",
+    ],
+    jvm_flags = ["-Dblaze.os=Windows"],
+    deps = [
+        ":foundations_testutil",
+        ":test_runner",
+        ":testutil",
+        "//src/main/java:bazel-core",
+        "//third_party:guava",
+        "//third_party:guava-testlib",
+        "//third_party:junit4",
+        "//third_party:truth",
+    ],
+)
+
+java_test(
+    name = "actions_test",
+    srcs = glob([
+        "com/google/devtools/build/lib/actions/**/*.java",
+    ]),
+    args = ["com.google.devtools.build.lib.AllTests"],
+    data = [
+        "//src/main/native:libunix.dylib",
+        "//src/main/native:libunix.so",
+    ],
+    deps = [
+        ":foundations_testutil",
+        ":test_runner",
+        ":testutil",
+        "//src/main/java:bazel-core",
+        "//third_party:guava",
+        "//third_party:guava-testlib",
+        "//third_party:jsr305",
+        "//third_party:junit4",
+        "//third_party:mockito",
+        "//third_party:truth",
+    ],
+)
diff --git a/src/test/java/com/google/devtools/build/lib/AllTests.java b/src/test/java/com/google/devtools/build/lib/AllTests.java
new file mode 100644
index 0000000..6aa19f1
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/AllTests.java
@@ -0,0 +1,25 @@
+// Copyright 2014 Google Inc. 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;
+
+import com.google.devtools.build.lib.testutil.ClasspathSuite;
+
+import org.junit.runner.RunWith;
+
+/**
+ * Test suite for options parsing framework.
+ */
+@RunWith(ClasspathSuite.class)
+public class AllTests {
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/ActionExecutionStatusReporterTest.java b/src/test/java/com/google/devtools/build/lib/actions/ActionExecutionStatusReporterTest.java
new file mode 100644
index 0000000..8af46e4
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/ActionExecutionStatusReporterTest.java
@@ -0,0 +1,293 @@
+// Copyright 2015 Google Inc. 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.actions;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.when;
+
+import com.google.common.base.Preconditions;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.eventbus.EventBus;
+import com.google.devtools.build.lib.events.EventCollector;
+import com.google.devtools.build.lib.events.EventKind;
+import com.google.devtools.build.lib.events.Reporter;
+import com.google.devtools.build.lib.util.Clock;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Test for the {@link ActionExecutionStatusReporter} class.
+ */
+@RunWith(JUnit4.class)
+public class ActionExecutionStatusReporterTest {
+  private static final class MockClock implements Clock {
+    private long millis = 0;
+
+    public void advance() {
+      advanceBy(1000);
+    }
+
+    public void advanceBy(long millis) {
+      Preconditions.checkArgument(millis > 0);
+      this.millis += millis;
+    }
+
+    @Override
+    public long currentTimeMillis() {
+      return millis;
+    }
+
+    @Override
+    public long nanoTime() {
+      // There's no reason to use a nanosecond-precision for a mock clock.
+      return millis * 1000000L;
+    }
+  }
+
+  private EventCollector collector;
+  private ActionExecutionStatusReporter statusReporter;
+  private EventBus eventBus;
+  private MockClock clock = new MockClock();
+
+  private Action mockAction(String progressMessage) { return mockAction(progressMessage, false); }
+
+  private Action mockAction(String progressMessage, boolean remote) {
+    Action action = Mockito.mock(Action.class);
+    when(action.describeStrategy(null)).thenReturn(remote ? "remote" : "something else");
+    when(action.getProgressMessage()).thenReturn(progressMessage);
+    if (progressMessage == null) {
+      when(action.prettyPrint()).thenReturn("default message");
+    }
+    return action;
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    collector = new EventCollector(EventKind.ALL_EVENTS);
+    Reporter reporter = new Reporter();
+    reporter.addHandler(collector);
+    statusReporter = ActionExecutionStatusReporter.create(reporter, clock);
+    eventBus = new EventBus();
+    eventBus.register(statusReporter);
+  }
+
+  private void verifyNoOutput() {
+    collector.clear();
+    statusReporter.showCurrentlyExecutingActions("");
+    assertEquals(0, collector.count());
+  }
+
+  private void verifyOutput(String... lines) throws Exception {
+    collector.clear();
+    statusReporter.showCurrentlyExecutingActions("");
+    assertThat(Splitter.on("\n").omitEmptyStrings().trimResults().split(
+        Iterables.getOnlyElement(collector).getMessage().replaceAll(" +", " ")))
+        .containsExactlyElementsIn(Arrays.asList(lines)).inOrder();
+  }
+
+  private void verifyWarningOutput(String... lines) throws Exception {
+    collector.clear();
+    statusReporter.warnAboutCurrentlyExecutingActions();
+    assertThat(Splitter.on("\n").omitEmptyStrings().trimResults().split(
+        Iterables.getOnlyElement(collector).getMessage().replaceAll(" +", " ")))
+        .containsExactlyElementsIn(Arrays.asList(lines)).inOrder();
+  }
+
+  @Test
+  public void testCategories() throws Exception {
+    verifyNoOutput();
+    verifyWarningOutput("There are no active jobs - stopping the build");
+    setPreparing(mockAction("action1"));
+    clock.advance();
+    verifyWarningOutput("Still waiting for unfinished jobs");
+    setScheduling(mockAction("action2"));
+    clock.advance();
+    setRunning(mockAction("action3", true));
+    clock.advance();
+    setRunning(mockAction("action4", false));
+    verifyOutput("Still waiting for 4 jobs to complete:",
+        "Preparing:", "action1, 3 s",
+        "Running (remote):", "action3, 1 s",
+        "Running (something else):", "action4, 0 s",
+        "Scheduling:", "action2, 2 s");
+    verifyWarningOutput("Still waiting for 3 jobs to complete:",
+        "Running (remote):", "action3, 1 s",
+        "Running (something else):", "action4, 0 s",
+        "Scheduling:", "action2, 2 s",
+        "Build will be stopped after these tasks terminate");
+  }
+
+  @Test
+  public void testSingleAction() throws Exception {
+    Action action = mockAction("action1", true);
+    verifyNoOutput();
+    setPreparing(action);
+    clock.advanceBy(1200);
+    verifyOutput("Still waiting for 1 job to complete:", "Preparing:", "action1, 1 s");
+    clock.advanceBy(5000);
+
+    setScheduling(action);
+    clock.advanceBy(1200);
+    // Only started *scheduling* 1200 ms ago, not 6200 ms ago.
+    verifyOutput("Still waiting for 1 job to complete:", "Scheduling:", "action1, 1 s");
+    setRunning(action);
+    clock.advanceBy(3000);
+    // Only started *running* 3000 ms ago, not 4200 ms ago.
+    verifyOutput("Still waiting for 1 job to complete:", "Running (remote):", "action1, 3 s");
+    statusReporter.remove(action);
+    verifyNoOutput();
+  }
+
+  @Test
+  public void testDynamicUpdate() throws Exception {
+    Action action = mockAction("action1", true);
+    verifyNoOutput();
+    setPreparing(action);
+    clock.advance();
+    verifyOutput("Still waiting for 1 job to complete:", "Preparing:", "action1, 1 s");
+    setScheduling(action);
+    clock.advance();
+    verifyOutput("Still waiting for 1 job to complete:", "Scheduling:", "action1, 1 s");
+    setRunning(action);
+    clock.advance();
+    verifyOutput("Still waiting for 1 job to complete:", "Running (remote):", "action1, 1 s");
+    clock.advance();
+
+    eventBus.post(ActionStatusMessage.analysisStrategy(action));
+    // Locality strategy was changed, so timer was reset to 0 s.
+    verifyOutput("Still waiting for 1 job to complete:", "Analyzing:", "action1, 0 s");
+    statusReporter.remove(action);
+    verifyNoOutput();
+  }
+
+  @Test
+  public void testGroups() throws Exception {
+    verifyNoOutput();
+    List<Action> actions = ImmutableList.of(
+        mockAction("remote1", true), mockAction("remote2", true), mockAction("remote3", true),
+        mockAction("local1", false), mockAction("local2", false), mockAction("local3", false));
+
+    for (Action a : actions) {
+      setScheduling(a);
+      clock.advance();
+    }
+
+    verifyOutput("Still waiting for 6 jobs to complete:",
+        "Scheduling:",
+        "remote1, 6 s", "remote2, 5 s", "remote3, 4 s",
+        "local1, 3 s", "local2, 2 s", "local3, 1 s");
+
+    for (Action a : actions) {
+      setRunning(a);
+      clock.advanceBy(2000);
+    }
+
+    // Timers got reset because now they are no longer scheduling but running.
+    verifyOutput("Still waiting for 6 jobs to complete:",
+        "Running (remote):", "remote1, 12 s", "remote2, 10 s", "remote3, 8 s",
+        "Running (something else):", "local1, 6 s", "local2, 4 s", "local3, 2 s");
+
+    statusReporter.remove(actions.get(0));
+    verifyOutput("Still waiting for 5 jobs to complete:",
+        "Running (remote):", "remote2, 10 s", "remote3, 8 s",
+        "Running (something else):", "local1, 6 s", "local2, 4 s", "local3, 2 s");
+  }
+
+  @Test
+  public void testTruncation() throws Exception {
+    verifyNoOutput();
+    List<Action> actions = new ArrayList<>();
+    for (int i = 1; i <= 100; i++) {
+      Action a = mockAction("a" + i);
+      actions.add(a);
+      setScheduling(a);
+      clock.advance();
+    }
+    verifyOutput("Still waiting for 100 jobs to complete:", "Scheduling:",
+        "a1, 100 s", "a2, 99 s", "a3, 98 s", "a4, 97 s", "a5, 96 s",
+        "a6, 95 s", "a7, 94 s", "a8, 93 s", "a9, 92 s", "... 91 more jobs");
+
+    for (int i = 0; i < 5; i++) {
+      setRunning(actions.get(i));
+      clock.advance();
+    }
+    verifyOutput("Still waiting for 100 jobs to complete:",
+        "Running (something else):", "a1, 5 s", "a2, 4 s", "a3, 3 s", "a4, 2 s", "a5, 1 s",
+        "Scheduling:", "a6, 100 s", "a7, 99 s", "a8, 98 s", "a9, 97 s", "a10, 96 s",
+        "a11, 95 s", "a12, 94 s", "a13, 93 s", "a14, 92 s", "... 86 more jobs");
+  }
+
+  @Test
+  public void testOrdering() throws Exception {
+    verifyNoOutput();
+    setScheduling(mockAction("a1"));
+    clock.advance();
+    setPreparing(mockAction("b1"));
+    clock.advance();
+    setPreparing(mockAction("b2"));
+    clock.advance();
+    setScheduling(mockAction("a2"));
+    clock.advance();
+    verifyOutput("Still waiting for 4 jobs to complete:",
+        "Preparing:", "b1, 3 s", "b2, 2 s",
+        "Scheduling:", "a1, 4 s", "a2, 1 s");
+  }
+
+  @Test
+  public void testNoProgressMessage() throws Exception {
+    verifyNoOutput();
+    setScheduling(mockAction(null));
+    verifyOutput("Still waiting for 1 job to complete:", "Scheduling:", "default message, 0 s");
+  }
+
+  @Test
+  public void testWaitTimeCalculation() throws Exception {
+    // --progress_report_interval=0
+    assertEquals(10, ActionExecutionStatusReporter.getWaitTime(0, 0));
+    assertEquals(30, ActionExecutionStatusReporter.getWaitTime(0, 10));
+    assertEquals(60, ActionExecutionStatusReporter.getWaitTime(0, 30));
+    assertEquals(60, ActionExecutionStatusReporter.getWaitTime(0, 60));
+
+    // --progress_report_interval=42
+    assertEquals(42, ActionExecutionStatusReporter.getWaitTime(42, 0));
+    assertEquals(42, ActionExecutionStatusReporter.getWaitTime(42, 42));
+
+    // --progress_report_interval=30 (looks like one of the default timeout stages)
+    assertEquals(30, ActionExecutionStatusReporter.getWaitTime(30, 0));
+    assertEquals(30, ActionExecutionStatusReporter.getWaitTime(30, 30));
+  }
+
+  private void setScheduling(ActionMetadata action) {
+    eventBus.post(ActionStatusMessage.schedulingStrategy(action));
+  }
+
+  private void setPreparing(ActionMetadata action) {
+    eventBus.post(ActionStatusMessage.preparingStrategy(action));
+  }
+
+  private void setRunning(ActionMetadata action) {
+    eventBus.post(ActionStatusMessage.runningStrategy(action));
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/ArtifactFactoryTest.java b/src/test/java/com/google/devtools/build/lib/actions/ArtifactFactoryTest.java
new file mode 100644
index 0000000..71cf9c4
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/ArtifactFactoryTest.java
@@ -0,0 +1,246 @@
+// Copyright 2015 Google Inc. 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.actions;
+
+
+import static com.google.devtools.build.lib.actions.util.ActionsTestUtil.NULL_ACTION_OWNER;
+import static com.google.devtools.build.lib.actions.util.ActionsTestUtil.NULL_ARTIFACT_OWNER;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import com.google.devtools.build.lib.actions.MutableActionGraph.ActionConflictException;
+import com.google.devtools.build.lib.actions.util.ActionsTestUtil;
+import com.google.devtools.build.lib.packages.PackageIdentifier;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.build.lib.vfs.util.FsApparatus;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+
+/**
+ * Tests {@link ArtifactFactory}. Also see {@link ArtifactTest} for a test
+ * of individual artifacts.
+ */
+@RunWith(JUnit4.class)
+public class ArtifactFactoryTest {
+
+  private FsApparatus scratch = FsApparatus.newInMemory();
+
+  private Path execRoot;
+  private Root clientRoot;
+  private Root clientRoRoot;
+  private Root outRoot;
+
+  private PathFragment fooPath;
+  private PackageIdentifier fooPackage;
+  private PathFragment fooRelative;
+
+  private PathFragment barPath;
+  private PackageIdentifier barPackage;
+  private PathFragment barRelative;
+
+  private ArtifactFactory artifactFactory;
+
+  @Before
+  public void setUp() throws Exception {
+    execRoot = scratch.dir("/output/workspace");
+    clientRoot = Root.asSourceRoot(scratch.dir("/client/workspace"));
+    clientRoRoot = Root.asSourceRoot(scratch.dir("/client/RO/workspace"));
+    outRoot = Root.asDerivedRoot(execRoot, execRoot.getRelative("out-root/x/bin"));
+
+    fooPath = new PathFragment("foo");
+    fooPackage = PackageIdentifier.createInDefaultRepo(fooPath);
+    fooRelative = fooPath.getRelative("foosource.txt");
+
+    barPath = new PathFragment("foo/bar");
+    barPackage = PackageIdentifier.createInDefaultRepo(barPath);
+    barRelative = barPath.getRelative("barsource.txt");
+
+    artifactFactory = new ArtifactFactory(execRoot);
+    setupRoots();
+  }
+
+  private void setupRoots() {
+    Map<PackageIdentifier, Root> packageRootMap = new HashMap<>();
+    packageRootMap.put(fooPackage, clientRoot);
+    packageRootMap.put(barPackage, clientRoRoot);
+    artifactFactory.setPackageRoots(packageRootMap);
+    artifactFactory.setDerivedArtifactRoots(ImmutableList.of(outRoot));
+  }
+
+  @Test
+  public void testGetSourceArtifactYieldsSameArtifact() throws Exception {
+    assertSame(artifactFactory.getSourceArtifact(fooRelative, clientRoot),
+               artifactFactory.getSourceArtifact(fooRelative, clientRoot));
+  }
+
+  @Test
+  public void testGetSourceArtifactUnnormalized() throws Exception {
+    assertSame(artifactFactory.getSourceArtifact(fooRelative, clientRoot),
+               artifactFactory.getSourceArtifact(new PathFragment("foo/./foosource.txt"),
+                   clientRoot));
+  }
+
+  @Test
+  public void testResolveArtifact_noDerived_simpleSource() throws Exception {
+    assertSame(artifactFactory.getSourceArtifact(fooRelative, clientRoot),
+        artifactFactory.resolveSourceArtifact(fooRelative));
+    assertSame(artifactFactory.getSourceArtifact(barRelative, clientRoRoot),
+        artifactFactory.resolveSourceArtifact(barRelative));
+  }
+
+  @Test
+  public void testResolveArtifact_noDerived_derivedRoot() throws Exception {
+    assertNull(artifactFactory.resolveSourceArtifact(
+            outRoot.getPath().getRelative(fooRelative).relativeTo(execRoot)));
+    assertNull(artifactFactory.resolveSourceArtifact(
+            outRoot.getPath().getRelative(barRelative).relativeTo(execRoot)));
+  }
+
+  @Test
+  public void testResolveArtifact_noDerived_simpleSource_other() throws Exception {
+    Artifact actual = artifactFactory.resolveSourceArtifact(fooRelative);
+    assertSame(artifactFactory.getSourceArtifact(fooRelative, clientRoot), actual);
+    actual = artifactFactory.resolveSourceArtifact(barRelative);
+    assertSame(artifactFactory.getSourceArtifact(barRelative, clientRoRoot), actual);
+  }
+
+  @Test
+  public void testClearResetsFactory() {
+    Artifact fooArtifact = artifactFactory.getSourceArtifact(fooRelative, clientRoot);
+    artifactFactory.clear();
+    setupRoots();
+    assertNotSame(fooArtifact, artifactFactory.getSourceArtifact(fooRelative, clientRoot));
+  }
+
+  @Test
+  public void testFindDerivedRoot() throws Exception {
+    assertSame(outRoot,
+        artifactFactory.findDerivedRoot(outRoot.getPath().getRelative(fooRelative)));
+    assertSame(outRoot,
+        artifactFactory.findDerivedRoot(outRoot.getPath().getRelative(barRelative)));
+  }
+
+  @Test
+  public void testSetGeneratingActionIdempotenceNewActionGraph() throws Exception {
+    Artifact a = artifactFactory.getDerivedArtifact(fooRelative, outRoot, NULL_ARTIFACT_OWNER);
+    Artifact b = artifactFactory.getDerivedArtifact(barRelative, outRoot, NULL_ARTIFACT_OWNER);
+    MutableActionGraph actionGraph = new MapBasedActionGraph();
+    Action originalAction = new ActionsTestUtil.NullAction(NULL_ACTION_OWNER, a);
+    actionGraph.registerAction(originalAction);
+
+    // Creating a second Action referring to the Artifact should create a conflict.
+    try {
+      Action action = new ActionsTestUtil.NullAction(NULL_ACTION_OWNER, a, b);
+      actionGraph.registerAction(action);
+      fail();
+    } catch (ActionConflictException e) {
+      assertSame(a, e.getArtifact());
+      assertSame(originalAction, actionGraph.getGeneratingAction(a));
+    }
+  }
+
+  @Test
+  public void testGetDerivedArtifact() throws Exception {
+    PathFragment toolPath = new PathFragment("_bin/tool");
+    Artifact artifact = artifactFactory.getDerivedArtifact(toolPath);
+    assertEquals(toolPath, artifact.getExecPath());
+    assertEquals(Root.asDerivedRoot(execRoot), artifact.getRoot());
+    assertEquals(execRoot.getRelative(toolPath), artifact.getPath());
+    assertNull(artifact.getOwner());
+  }
+
+  @Test
+  public void testGetDerivedArtifactFailsForAbsolutePath() throws Exception {
+    try {
+      artifactFactory.getDerivedArtifact(new PathFragment("/_bin/b"));
+      fail();
+    } catch (IllegalArgumentException e) {
+      // Expected exception
+    }
+  }
+
+  private static class MockPackageRootResolver implements PackageRootResolver {
+    private Map<PathFragment, Root> packageRoots = Maps.newHashMap();
+
+    public void setPackageRoots(Map<PackageIdentifier, Root> packageRoots) {
+      for (Entry<PackageIdentifier, Root> packageRoot : packageRoots.entrySet()) {
+        this.packageRoots.put(packageRoot.getKey().getPackageFragment(), packageRoot.getValue());
+      }
+    }
+
+    @Override
+    public Map<PathFragment, Root> findPackageRoots(Iterable<PathFragment> execPaths) {
+      Map<PathFragment, Root> result = new HashMap<>();
+      for (PathFragment execPath : execPaths) {
+        for (PathFragment dir = execPath.getParentDirectory(); dir != null;
+            dir = dir.getParentDirectory()) {
+          if (packageRoots.get(dir) != null) {
+            result.put(execPath, packageRoots.get(dir));
+          }
+        }
+        if (result.get(execPath) == null) {
+          result.put(execPath, null);
+        }
+      }
+      return result;
+    }
+  }
+
+  @Test
+  public void testArtifactDeserializationWithoutReusedArtifacts() throws Exception {
+    PathFragment derivedPath = outRoot.getExecPath().getRelative("fruit/banana");
+    artifactFactory.clear();
+    artifactFactory.setDerivedArtifactRoots(ImmutableList.of(outRoot));
+    MockPackageRootResolver rootResolver = new MockPackageRootResolver();
+    rootResolver.setPackageRoots(
+        ImmutableMap.of(PackageIdentifier.createInDefaultRepo(""), clientRoot));
+    Artifact artifact1 = artifactFactory.deserializeArtifact(derivedPath, rootResolver);
+    Artifact artifact2 = artifactFactory.deserializeArtifact(derivedPath, rootResolver);
+    assertEquals(artifact1, artifact2);
+    assertNull(artifact1.getOwner());
+    assertNull(artifact2.getOwner());
+    assertEquals(derivedPath, artifact1.getExecPath());
+    assertEquals(derivedPath, artifact2.getExecPath());
+
+    // Source artifacts are always reused
+    PathFragment sourcePath = clientRoot.getExecPath().getRelative("fruit/mango");
+    artifact1 = artifactFactory.deserializeArtifact(sourcePath, rootResolver);
+    artifact2 = artifactFactory.deserializeArtifact(sourcePath, rootResolver);
+    assertSame(artifact1, artifact2);
+    assertEquals(sourcePath, artifact1.getExecPath());
+  }
+
+  @Test
+  public void testDeserializationWithInvalidPath() throws Exception {
+    artifactFactory.clear();
+    PathFragment randomPath = new PathFragment("maracuja/lemon/kiwi");
+    Artifact artifact = artifactFactory.deserializeArtifact(randomPath,
+        new MockPackageRootResolver());
+    assertNull(artifact);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/ArtifactTest.java b/src/test/java/com/google/devtools/build/lib/actions/ArtifactTest.java
new file mode 100644
index 0000000..d493e42
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/ArtifactTest.java
@@ -0,0 +1,312 @@
+// Copyright 2015 Google Inc. 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.actions;
+
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.build.lib.testutil.MoreAsserts.assertSameContents;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.devtools.build.lib.actions.Action.MiddlemanType;
+import com.google.devtools.build.lib.actions.util.ActionsTestUtil;
+import com.google.devtools.build.lib.actions.util.LabelArtifactOwner;
+import com.google.devtools.build.lib.rules.cpp.CppFileTypes;
+import com.google.devtools.build.lib.rules.java.JavaSemantics;
+import com.google.devtools.build.lib.syntax.Label;
+import com.google.devtools.build.lib.testutil.Scratch;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(JUnit4.class)
+public class ArtifactTest {
+  private Scratch scratch;
+  private Path execDir;
+  private Root rootDir;
+
+  @Before
+  public void setUp() throws Exception {
+    scratch = new Scratch();
+    execDir = scratch.dir("/exec");
+    rootDir = Root.asDerivedRoot(scratch.dir("/exec/root"));
+  }
+
+  @Test
+  public void testConstruction_badRootDir() throws IOException {
+    Path f1 = scratch.file("/exec/dir/file.ext");
+    Path bogusDir = scratch.file("/exec/dir/bogus");
+    try {
+      new Artifact(f1, Root.asDerivedRoot(bogusDir), f1.relativeTo(execDir));
+      fail("Expected IllegalArgumentException constructing artifact with a bad root dir");
+    } catch (IllegalArgumentException expected) {}
+  }
+
+  @Test
+  public void testEquivalenceRelation() throws Exception {
+    PathFragment aPath = new PathFragment("src/a");
+    PathFragment bPath = new PathFragment("src/b");
+    assertEquals(new Artifact(aPath, rootDir),
+                 new Artifact(aPath, rootDir));
+    assertEquals(new Artifact(bPath, rootDir),
+                 new Artifact(bPath, rootDir));
+    assertFalse(new Artifact(aPath, rootDir).equals(
+                new Artifact(bPath, rootDir)));
+  }
+
+  @Test
+  public void testComparison() throws Exception {
+    PathFragment aPath = new PathFragment("src/a");
+    PathFragment bPath = new PathFragment("src/b");
+    Artifact aArtifact = new Artifact(aPath, rootDir);
+    Artifact bArtifact = new Artifact(bPath, rootDir);
+    assertEquals(-1, aArtifact.compareTo(bArtifact));
+    assertEquals(0, aArtifact.compareTo(aArtifact));
+    assertEquals(0, bArtifact.compareTo(bArtifact));
+    assertEquals(1, bArtifact.compareTo(aArtifact));
+  }
+
+  @Test
+  public void testRootPrefixedExecPath_normal() throws IOException {
+    Path f1 = scratch.file("/exec/root/dir/file.ext");
+    Artifact a1 = new Artifact(f1, rootDir, f1.relativeTo(execDir));
+    assertEquals("root:dir/file.ext", Artifact.asRootPrefixedExecPath(a1));
+  }
+
+  @Test
+  public void testRootPrefixedExecPath_noRoot() throws IOException {
+    Path f1 = scratch.file("/exec/dir/file.ext");
+    Artifact a1 = new Artifact(f1.relativeTo(execDir), Root.asDerivedRoot(execDir));
+    assertEquals(":dir/file.ext", Artifact.asRootPrefixedExecPath(a1));
+  }
+
+  @Test
+  public void testRootPrefixedExecPath_nullRootDir() throws IOException {
+    Path f1 = scratch.file("/exec/dir/file.ext");
+    try {
+      new Artifact(f1, null, f1.relativeTo(execDir));
+      fail("Expected IllegalArgumentException creating artifact with null root");
+    } catch (IllegalArgumentException expected) {}
+  }
+
+  @Test
+  public void testRootPrefixedExecPaths() throws IOException {
+    Path f1 = scratch.file("/exec/root/dir/file1.ext");
+    Path f2 = scratch.file("/exec/root/dir/dir/file2.ext");
+    Path f3 = scratch.file("/exec/root/dir/dir/dir/file3.ext");
+    Artifact a1 = new Artifact(f1, rootDir, f1.relativeTo(execDir));
+    Artifact a2 = new Artifact(f2, rootDir, f2.relativeTo(execDir));
+    Artifact a3 = new Artifact(f3, rootDir, f3.relativeTo(execDir));
+    List<String> strings = new ArrayList<>();
+    Artifact.addRootPrefixedExecPaths(Lists.newArrayList(a1, a2, a3), strings);
+    assertThat(strings).containsExactly(
+        "root:dir/file1.ext",
+        "root:dir/dir/file2.ext",
+        "root:dir/dir/dir/file3.ext").inOrder();
+  }
+
+  @Test
+  public void testGetFilename() throws Exception {
+    Root root = Root.asSourceRoot(scratch.dir("/foo"));
+    Artifact javaFile = new Artifact(scratch.file("/foo/Bar.java"), root);
+    Artifact generatedHeader = new Artifact(scratch.file("/foo/bar.proto.h"), root);
+    Artifact generatedCc = new Artifact(scratch.file("/foo/bar.proto.cc"), root);
+    Artifact aCPlusPlusFile = new Artifact(scratch.file("/foo/bar.cc"), root);
+    assertTrue(JavaSemantics.JAVA_SOURCE.matches(javaFile.getFilename()));
+    assertTrue(CppFileTypes.CPP_HEADER.matches(generatedHeader.getFilename()));
+    assertTrue(CppFileTypes.CPP_SOURCE.matches(generatedCc.getFilename()));
+    assertTrue(CppFileTypes.CPP_SOURCE.matches(aCPlusPlusFile.getFilename()));
+  }
+
+  @Test
+  public void testMangledPath() {
+    String path = "dir/sub_dir/name:end";
+    assertEquals("dir_Ssub_Udir_Sname_Cend", Actions.escapedPath(path));
+  }
+
+  private List<Artifact> getFooBarArtifacts(MutableActionGraph actionGraph, boolean collapsedList)
+      throws Exception {
+    Root root = Root.asSourceRoot(scratch.dir("/foo"));
+    Artifact aHeader1 = new Artifact(scratch.file("/foo/bar1.h"), root);
+    Artifact aHeader2 = new Artifact(scratch.file("/foo/bar2.h"), root);
+    Artifact aHeader3 = new Artifact(scratch.file("/foo/bar3.h"), root);
+    Artifact middleman = new Artifact(new PathFragment("middleman"),
+        Root.middlemanRoot(scratch.dir("/foo"), scratch.dir("/foo/out")));
+    actionGraph.registerAction(new MiddlemanAction(ActionsTestUtil.NULL_ACTION_OWNER,
+        ImmutableList.of(aHeader1, aHeader2, aHeader3), middleman, "desc",
+        MiddlemanType.AGGREGATING_MIDDLEMAN));
+    return collapsedList ? Lists.newArrayList(aHeader1, middleman) :
+        Lists.newArrayList(aHeader1, aHeader2, middleman);
+  }
+
+  @Test
+  public void testAddExecPaths() throws Exception {
+    List<String> paths = new ArrayList<>();
+    MutableActionGraph actionGraph = new MapBasedActionGraph();
+    Artifact.addExecPaths(getFooBarArtifacts(actionGraph, false), paths);
+    assertSameContents(ImmutableList.of("bar1.h", "bar2.h"), paths);
+  }
+
+  @Test
+  public void testAddExpandedExecPathStrings() throws Exception {
+    List<String> paths = new ArrayList<>();
+    MutableActionGraph actionGraph = new MapBasedActionGraph();
+    Artifact.addExpandedExecPathStrings(getFooBarArtifacts(actionGraph, true), paths,
+        ActionInputHelper.actionGraphMiddlemanExpander(actionGraph));
+    assertSameContents(ImmutableList.of("bar1.h", "bar2.h", "bar3.h"), paths);
+  }
+
+  @Test
+  public void testAddExpandedExecPaths() throws Exception {
+    List<PathFragment> paths = new ArrayList<>();
+    MutableActionGraph actionGraph = new MapBasedActionGraph();
+    Artifact.addExpandedExecPaths(getFooBarArtifacts(actionGraph, true), paths,
+        ActionInputHelper.actionGraphMiddlemanExpander(actionGraph));
+    assertSameContents(ImmutableList.of(
+        new PathFragment("bar1.h"), new PathFragment("bar2.h"), new PathFragment("bar3.h")),
+        paths);
+  }
+
+  @Test
+  public void testAddExpandedArtifacts() throws Exception {
+    List<Artifact> expanded = new ArrayList<>();
+    MutableActionGraph actionGraph = new MapBasedActionGraph();
+    List<Artifact> original = getFooBarArtifacts(actionGraph, true);
+    Artifact.addExpandedArtifacts(original, expanded,
+        ActionInputHelper.actionGraphMiddlemanExpander(actionGraph));
+
+    List<Artifact> manuallyExpanded = new ArrayList<>();
+    for (Artifact artifact : original) {
+      Action action = actionGraph.getGeneratingAction(artifact);
+      if (artifact.isMiddlemanArtifact()) {
+        Iterables.addAll(manuallyExpanded, action.getInputs());
+      } else {
+        manuallyExpanded.add(artifact);
+      }
+    }
+    assertSameContents(manuallyExpanded, expanded);
+  }
+
+  @Test
+  public void testAddExecPathsNewActionGraph() throws Exception {
+    List<String> paths = new ArrayList<>();
+    MutableActionGraph actionGraph = new MapBasedActionGraph();
+    Artifact.addExecPaths(getFooBarArtifacts(actionGraph, false), paths);
+    assertSameContents(ImmutableList.of("bar1.h", "bar2.h"), paths);
+  }
+
+  @Test
+  public void testAddExpandedExecPathStringsNewActionGraph() throws Exception {
+    List<String> paths = new ArrayList<>();
+    MutableActionGraph actionGraph = new MapBasedActionGraph();
+    Artifact.addExpandedExecPathStrings(getFooBarArtifacts(actionGraph, true), paths,
+        ActionInputHelper.actionGraphMiddlemanExpander(actionGraph));
+    assertSameContents(ImmutableList.of("bar1.h", "bar2.h", "bar3.h"), paths);
+  }
+
+  @Test
+  public void testAddExpandedExecPathsNewActionGraph() throws Exception {
+    List<PathFragment> paths = new ArrayList<>();
+    MutableActionGraph actionGraph = new MapBasedActionGraph();
+    Artifact.addExpandedExecPaths(getFooBarArtifacts(actionGraph, true), paths,
+        ActionInputHelper.actionGraphMiddlemanExpander(actionGraph));
+    assertSameContents(ImmutableList.of(
+        new PathFragment("bar1.h"), new PathFragment("bar2.h"), new PathFragment("bar3.h")),
+        paths);
+  }
+
+  @Test
+  public void testAddExpandedArtifactsNewActionGraph() throws Exception {
+    List<Artifact> expanded = new ArrayList<>();
+    MutableActionGraph actionGraph = new MapBasedActionGraph();
+    List<Artifact> original = getFooBarArtifacts(actionGraph, true);
+    Artifact.addExpandedArtifacts(original, expanded,
+        ActionInputHelper.actionGraphMiddlemanExpander(actionGraph));
+
+    List<Artifact> manuallyExpanded = new ArrayList<>();
+    for (Artifact artifact : original) {
+      Action action = actionGraph.getGeneratingAction(artifact);
+      if (artifact.isMiddlemanArtifact()) {
+        Iterables.addAll(manuallyExpanded, action.getInputs());
+      } else {
+        manuallyExpanded.add(artifact);
+      }
+    }
+    assertSameContents(manuallyExpanded, expanded);
+  }
+
+  @Test
+  public void testRootRelativePathIsSameAsExecPath() throws Exception {
+    Root root = Root.asSourceRoot(scratch.dir("/foo"));
+    Artifact a = new Artifact(scratch.file("/foo/bar1.h"), root);
+    assertSame(a.getExecPath(), a.getRootRelativePath());
+  }
+
+  @Test
+  public void testToDetailString() throws Exception {
+    Artifact a = new Artifact(scratch.file("/a/b/c"), Root.asDerivedRoot(scratch.dir("/a/b")),
+        new PathFragment("b/c"));
+    assertEquals("[[/a]b]c", a.toDetailString());
+  }
+
+  @Test
+  public void testWeirdArtifact() throws Exception {
+    try {
+      new Artifact(scratch.file("/a/b/c"), Root.asDerivedRoot(scratch.dir("/a")),
+          new PathFragment("c"));
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertEquals("c: illegal execPath doesn't end with b/c at /a/b/c with root /a[derived]",
+          e.getMessage());
+    }
+  }
+
+  @Test
+  public void testSerializeToString() throws Exception {
+    assertEquals("b/c /3",
+        new Artifact(scratch.file("/a/b/c"),
+            Root.asDerivedRoot(scratch.dir("/a"))).serializeToString());
+  }
+
+  @Test
+  public void testSerializeToStringWithExecPath() throws Exception {
+    Path path = scratch.file("/aaa/bbb/ccc");
+    Root root = Root.asDerivedRoot(scratch.dir("/aaa/bbb"));
+    PathFragment execPath = new PathFragment("bbb/ccc");
+
+    assertEquals("bbb/ccc /3", new Artifact(path, root, execPath).serializeToString());
+  }
+
+  @Test
+  public void testSerializeToStringWithOwner() throws Exception {
+    assertEquals("b/c /3 //foo:bar",
+        new Artifact(scratch.file("/aa/b/c"), Root.asDerivedRoot(scratch.dir("/aa")),
+            new PathFragment("b/c"),
+            new LabelArtifactOwner(Label.parseAbsoluteUnchecked("//foo:bar"))).serializeToString());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/ConcurrentMultimapWithHeadElementTest.java b/src/test/java/com/google/devtools/build/lib/actions/ConcurrentMultimapWithHeadElementTest.java
new file mode 100644
index 0000000..28f185c
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/ConcurrentMultimapWithHeadElementTest.java
@@ -0,0 +1,175 @@
+// Copyright 2015 Google Inc. 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.actions;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import com.google.common.testing.GcFinalization;
+import com.google.devtools.build.lib.concurrent.AbstractQueueVisitor;
+import com.google.devtools.build.lib.testutil.TestThread;
+import com.google.devtools.build.lib.testutil.TestUtils;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.lang.ref.WeakReference;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Tests for ConcurrentMultimapWithHeadElement.
+ */
+@RunWith(JUnit4.class)
+public class ConcurrentMultimapWithHeadElementTest {
+  @Test
+  public void testSmoke() throws Exception {
+    ConcurrentMultimapWithHeadElement<String, String> multimap =
+        new ConcurrentMultimapWithHeadElement<String, String>();
+    assertEquals("val", multimap.putAndGet("key", "val"));
+    assertEquals("val", multimap.get("key"));
+    assertEquals("val", multimap.putAndGet("key", "val2"));
+    multimap.remove("key", "val2");
+    assertEquals("val", multimap.get("key"));
+    assertEquals("val", multimap.putAndGet("key", "val2"));
+    multimap.remove("key", "val");
+    assertEquals("val2", multimap.get("key"));
+  }
+
+  @Test
+  public void testDuplicate() throws Exception {
+    ConcurrentMultimapWithHeadElement<String, String> multimap =
+        new ConcurrentMultimapWithHeadElement<String, String>();
+    assertEquals("val", multimap.putAndGet("key", "val"));
+    assertEquals("val", multimap.get("key"));
+    assertEquals("val", multimap.putAndGet("key", "val"));
+    multimap.remove("key", "val");
+    assertEquals(null, multimap.get("key"));
+  }
+
+  @Test
+  public void testDuplicateWithEqualsObject() throws Exception {
+    ConcurrentMultimapWithHeadElement<String, String> multimap =
+        new ConcurrentMultimapWithHeadElement<>();
+    assertEquals(new String("val"), multimap.putAndGet("key", new String("val")));
+    assertEquals(new String("val"), multimap.get("key"));
+    assertEquals(new String("val"), multimap.putAndGet("key", new String("val")));
+    multimap.remove("key", new String("val"));
+    assertEquals(null, multimap.get("key"));
+  }
+
+  @Test
+  public void testFailedRemoval() throws Exception {
+    ConcurrentMultimapWithHeadElement<String, String> multimap =
+        new ConcurrentMultimapWithHeadElement<String, String>();
+    assertEquals("val", multimap.putAndGet("key", "val"));
+    multimap.remove("key", "val2");
+    assertEquals("val", multimap.get("key"));
+  }
+
+  @Test
+  public void testNotEmpty() throws Exception {
+    ConcurrentMultimapWithHeadElement<String, String> multimap =
+        new ConcurrentMultimapWithHeadElement<String, String>();
+    assertEquals("val", multimap.putAndGet("key", "val"));
+    multimap.remove("key", "val2");
+    assertEquals("val", multimap.get("key"));
+  }
+
+  @Test
+  public void testKeyRemoved() throws Exception {
+    String key = new String("key");
+    ConcurrentMultimapWithHeadElement<String, String> multimap =
+        new ConcurrentMultimapWithHeadElement<String, String>();
+    assertEquals("val", multimap.putAndGet(key, "val"));
+    WeakReference<String> weakKey = new WeakReference<String>(key);
+    multimap.remove(key, "val");
+    key = null;
+    GcFinalization.awaitClear(weakKey);
+  }
+
+  @Test
+  public void testKeyRemovedAndAddedConcurrently() throws Exception {
+    final ConcurrentMultimapWithHeadElement<String, String> multimap =
+        new ConcurrentMultimapWithHeadElement<String, String>();
+    // Because we have two threads racing, run the test many times. Before fixed, there was a 90%
+    // chance of failure in 10,000 runs.
+    for (int i = 0; i < 10000; i++) {
+      assertEquals("val", multimap.putAndGet("key", "val"));
+      final CountDownLatch threadStart = new CountDownLatch(1);
+      TestThread testThread = new TestThread() {
+        @Override
+        public void runTest() throws Exception {
+          threadStart.countDown();
+          multimap.remove("key", "val");
+        }
+      };
+      testThread.start();
+      assertTrue(threadStart.await(TestUtils.WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+      assertNotNull(multimap.putAndGet("key", "val2")); // Removal may not have happened yet.
+      assertNotNull(multimap.get("key")); // If put failed, this will be null.
+      testThread.joinAndAssertState(2000);
+      multimap.clear();
+    }
+  }
+
+  private class StressTester extends AbstractQueueVisitor {
+    private final ConcurrentMultimapWithHeadElement<Boolean, Integer> multimap =
+        new ConcurrentMultimapWithHeadElement<Boolean, Integer>();
+    private final AtomicInteger actionCount = new AtomicInteger(0);
+
+    private StressTester() {
+      super(/*concurrent=*/true, 200, 200, 1, TimeUnit.SECONDS,
+          /*failFastOnException=*/true, /*failFastOnInterrupt=*/true, "action-graph-test");
+    }
+
+    private void addAndRemove(final Boolean key, final Integer add, final Integer remove) {
+      enqueue(new Runnable() {
+        @Override
+        public void run() {
+          assertNotNull(multimap.putAndGet(key, add));
+          multimap.remove(key, remove);
+          doRandom();
+        }
+      });
+    }
+
+    private Integer getRandomInt() {
+      return (int) Math.round(Math.random() * 3.0);
+    }
+
+    private void doRandom() {
+      if (actionCount.incrementAndGet() > 100000) {
+        return;
+      }
+      Boolean key = Math.random() < 0.5;
+      addAndRemove(key, getRandomInt(), getRandomInt());
+    }
+
+    private void work() throws InterruptedException {
+      work(/*failFastOnInterrupt=*/true);
+    }
+  }
+
+  @Test
+  public void testStressTest() throws Exception {
+    StressTester stressTester = new StressTester();
+    stressTester.doRandom();
+    stressTester.work();
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/CustomCommandLineTest.java b/src/test/java/com/google/devtools/build/lib/actions/CustomCommandLineTest.java
new file mode 100644
index 0000000..3a7db34
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/CustomCommandLineTest.java
@@ -0,0 +1,161 @@
+// Copyright 2015 Google Inc. 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.actions;
+
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.common.collect.ImmutableList;
+import com.google.devtools.build.lib.analysis.actions.CustomCommandLine;
+import com.google.devtools.build.lib.analysis.actions.CustomCommandLine.CustomArgv;
+import com.google.devtools.build.lib.analysis.actions.CustomCommandLine.CustomMultiArgv;
+import com.google.devtools.build.lib.collect.nestedset.NestedSetBuilder;
+import com.google.devtools.build.lib.syntax.Label;
+import com.google.devtools.build.lib.syntax.Label.SyntaxException;
+import com.google.devtools.build.lib.testutil.Scratch;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for CustomCommandLine.
+ */
+@RunWith(JUnit4.class)
+public class CustomCommandLineTest {
+
+  private Scratch scratch;
+  private Root rootDir;
+  private Artifact artifact1;
+  private Artifact artifact2;
+
+  @Before
+  public void setUp() throws Exception {
+    scratch = new Scratch();
+    rootDir = Root.asDerivedRoot(scratch.dir("/exec/root"));
+    artifact1 = new Artifact(scratch.file("/exec/root/dir/file1.txt"), rootDir);
+    artifact2 = new Artifact(scratch.file("/exec/root/dir/file2.txt"), rootDir);
+  }
+
+  @Test
+  public void testStringArgs() {
+    CustomCommandLine cl = CustomCommandLine.builder().add("--arg1").add("--arg2").build();
+    assertEquals(ImmutableList.of("--arg1", "--arg2"), cl.arguments());
+  }
+
+  @Test
+  public void testLabelArgs() throws SyntaxException {
+    CustomCommandLine cl = CustomCommandLine.builder().add(Label.parseAbsolute("//a:b")).build();
+    assertEquals(ImmutableList.of("//a:b"), cl.arguments());
+  }
+
+  @Test
+  public void testStringsArgs() {
+    CustomCommandLine cl = CustomCommandLine.builder().add("--arg",
+        ImmutableList.of("a", "b")).build();
+    assertEquals(ImmutableList.of("--arg", "a", "b"), cl.arguments());
+  }
+
+  @Test
+  public void testArtifactExecPathArgs() {
+    CustomCommandLine cl = CustomCommandLine.builder().addExecPath("--path", artifact1).build();
+    assertEquals(ImmutableList.of("--path", "dir/file1.txt"), cl.arguments());
+  }
+
+  @Test
+  public void testArtifactExecPathsArgs() {
+    CustomCommandLine cl = CustomCommandLine.builder().addExecPaths("--path",
+        ImmutableList.of(artifact1, artifact2)).build();
+    assertEquals(ImmutableList.of("--path", "dir/file1.txt", "dir/file2.txt"), cl.arguments());
+  }
+
+  @Test
+  public void testNestedSetArtifactExecPathsArgs() {
+    CustomCommandLine cl = CustomCommandLine.builder().addExecPaths(
+        NestedSetBuilder.<Artifact>stableOrder().add(artifact1).add(artifact2).build()).build();
+    assertEquals(ImmutableList.of("dir/file1.txt", "dir/file2.txt"), cl.arguments());
+  }
+
+  @Test
+  public void testArtifactJoinExecPathArgs() {
+    CustomCommandLine cl = CustomCommandLine.builder().addJoinExecPaths("--path", ":",
+        ImmutableList.of(artifact1, artifact2)).build();
+    assertEquals(ImmutableList.of("--path", "dir/file1.txt:dir/file2.txt"), cl.arguments());
+  }
+
+  @Test
+  public void testPathArgs() {
+    CustomCommandLine cl = CustomCommandLine.builder().addPath(artifact1.getExecPath()).build();
+    assertEquals(ImmutableList.of("dir/file1.txt"), cl.arguments());
+  }
+
+  @Test
+  public void testJoinPathArgs() {
+    CustomCommandLine cl = CustomCommandLine.builder().addJoinPaths(":",
+        ImmutableList.of(artifact1.getExecPath(), artifact2.getExecPath())).build();
+    assertEquals(ImmutableList.of("dir/file1.txt:dir/file2.txt"), cl.arguments());
+  }
+
+  @Test
+  public void testPathsArgs() {
+    CustomCommandLine cl = CustomCommandLine.builder().addPaths("%s:%s",
+        artifact1.getExecPath(), artifact1.getRootRelativePath()).build();
+    assertEquals(ImmutableList.of("dir/file1.txt:dir/file1.txt"), cl.arguments());
+  }
+
+  @Test
+  public void testCustomArgs() {
+    CustomCommandLine cl = CustomCommandLine.builder().add(new CustomArgv() {
+      @Override
+      public String argv() {
+        return "--arg";
+      }
+    }).build();
+    assertEquals(ImmutableList.of("--arg"), cl.arguments());
+  }
+
+  @Test
+  public void testCustomMultiArgs() {
+    CustomCommandLine cl = CustomCommandLine.builder().add(new CustomMultiArgv() {
+      @Override
+      public ImmutableList<String> argv() {
+        return ImmutableList.of("--arg1", "--arg2");
+      }
+    }).build();
+    assertEquals(ImmutableList.of("--arg1", "--arg2"), cl.arguments());
+  }
+
+  @Test
+  public void testCombinedArgs() {
+    CustomCommandLine cl = CustomCommandLine.builder()
+        .add("--arg")
+        .add("--args", ImmutableList.of("abc"))
+        .addExecPaths("--path1", ImmutableList.of(artifact1))
+        .addExecPath("--path2", artifact2)
+        .build();
+    assertEquals(ImmutableList.of("--arg", "--args", "abc", "--path1", "dir/file1.txt", "--path2",
+        "dir/file2.txt"), cl.arguments());
+  }
+
+  @Test
+  public void testAddNulls() {
+    CustomCommandLine cl = CustomCommandLine.builder()
+        .add("--args", null)
+        .addExecPaths(null, ImmutableList.of(artifact1))
+        .addExecPath(null, null)
+        .build();
+    assertEquals(ImmutableList.of(), cl.arguments());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/DigestUtilsTest.java b/src/test/java/com/google/devtools/build/lib/actions/DigestUtilsTest.java
new file mode 100644
index 0000000..2ed24a6
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/DigestUtilsTest.java
@@ -0,0 +1,145 @@
+// Copyright 2015 Google Inc. 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.actions;
+
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertTrue;
+
+import com.google.common.base.Strings;
+import com.google.devtools.build.lib.actions.cache.DigestUtils;
+import com.google.devtools.build.lib.testutil.TestThread;
+import com.google.devtools.build.lib.testutil.TestUtils;
+import com.google.devtools.build.lib.util.BlazeClock;
+import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.IOException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Tests for DigestUtils.
+ */
+@RunWith(JUnit4.class)
+public class DigestUtilsTest {
+
+  private static void assertMd5CalculationConcurrency(boolean expectConcurrent,
+      final boolean fastDigest, final int fileSize1, final int fileSize2) throws Exception {
+    final CountDownLatch barrierLatch = new CountDownLatch(2); // Used to block test threads.
+    final CountDownLatch readyLatch = new CountDownLatch(1);   // Used to block main thread.
+
+    FileSystem myfs = new InMemoryFileSystem(BlazeClock.instance()) {
+        @Override
+        protected byte[] getMD5Digest(Path path) throws IOException {
+          try {
+            barrierLatch.countDown();
+            readyLatch.countDown();
+            // Either both threads will be inside getMD5Digest at the same time or they
+            // both will be blocked.
+            barrierLatch.await();
+          } catch (Exception e) {
+            throw new IOException(e);
+          }
+          return super.getMD5Digest(path);
+        }
+
+        @Override
+        protected String getFastDigestFunctionType(Path path) {
+          return "MD5";
+        }
+
+        @Override
+        protected byte[] getFastDigest(Path path) throws IOException {
+          return fastDigest ? super.getMD5Digest(path) : null;
+        }
+    };
+
+    final Path myFile1 = myfs.getPath("/f1.dat");
+    final Path myFile2 = myfs.getPath("/f2.dat");
+    FileSystemUtils.writeContentAsLatin1(myFile1, Strings.repeat("a", fileSize1));
+    FileSystemUtils.writeContentAsLatin1(myFile2, Strings.repeat("b", fileSize2));
+
+     TestThread thread1 = new TestThread () {
+       @Override public void runTest() throws Exception {
+         DigestUtils.getDigestOrFail(myFile1, fileSize1);
+       }
+     };
+
+     TestThread thread2 = new TestThread () {
+       @Override public void runTest() throws Exception {
+         DigestUtils.getDigestOrFail(myFile2, fileSize2);
+       }
+     };
+
+     thread1.start();
+     thread2.start();
+     if (!expectConcurrent) { // Synchronized case.
+       // Wait until at least one thread reached getMD5Digest().
+       assertTrue(readyLatch.await(TestUtils.WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+       // Only 1 thread should be inside getMD5Digest().
+       assertEquals(1, barrierLatch.getCount());
+       barrierLatch.countDown(); // Release barrier latch, allowing both threads to proceed.
+     }
+     // Test successful execution within 5 seconds.
+     thread1.joinAndAssertState(TestUtils.WAIT_TIMEOUT_MILLISECONDS);
+     thread2.joinAndAssertState(TestUtils.WAIT_TIMEOUT_MILLISECONDS);
+  }
+
+  /**
+   * Ensures that MD5 calculation is synchronized for files
+   * greater than 4096 bytes if MD5 is not available cheaply,
+   * so machines with rotating drives don't become unusable.
+   */
+  @Test
+  public void testMd5CalculationConcurrency() throws Exception {
+    assertMd5CalculationConcurrency(true, true, 4096, 4096);
+    assertMd5CalculationConcurrency(true, true, 4097, 4097);
+    assertMd5CalculationConcurrency(true, false, 4096, 4096);
+    assertMd5CalculationConcurrency(false, false, 4097, 4097);
+    assertMd5CalculationConcurrency(true, false, 1024, 4097);
+    assertMd5CalculationConcurrency(true, false, 1024, 1024);
+  }
+
+  @Test
+  public void testRecoverFromMalformedDigest() throws Exception {
+    final byte[] malformed = {0, 0, 0};
+    FileSystem myFS = new InMemoryFileSystem(BlazeClock.instance()) {
+      @Override
+      protected String getFastDigestFunctionType(Path path) {
+        return "MD5";
+      }
+
+      @Override
+      protected byte[] getFastDigest(Path path) throws IOException {
+        // MD5 digests are supposed to be 16 bytes.
+        return malformed;
+      }
+    };
+    Path path = myFS.getPath("/file");
+    FileSystemUtils.writeContentAsLatin1(path, "a");
+    byte[] result = DigestUtils.getDigestOrFail(path, 1);
+    assertArrayEquals(path.getMD5Digest(), result);
+    assertNotSame(malformed, result);
+    assertEquals(16, result.length);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/ExecutableSymlinkActionTest.java b/src/test/java/com/google/devtools/build/lib/actions/ExecutableSymlinkActionTest.java
new file mode 100644
index 0000000..50d0f25
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/ExecutableSymlinkActionTest.java
@@ -0,0 +1,105 @@
+// Copyright 2015 Google Inc. 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.actions;
+
+
+import static com.google.devtools.build.lib.actions.util.ActionsTestUtil.NULL_ACTION_OWNER;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.devtools.build.lib.actions.util.DummyExecutor;
+import com.google.devtools.build.lib.analysis.actions.ExecutableSymlinkAction;
+import com.google.devtools.build.lib.exec.SingleBuildFileCache;
+import com.google.devtools.build.lib.testutil.Scratch;
+import com.google.devtools.build.lib.testutil.TestFileOutErr;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.Path;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ExecutableSymlinkActionTest {
+  private Scratch scratch = new Scratch();
+  private Root inputRoot;
+  private Root outputRoot;
+  TestFileOutErr outErr;
+  private Executor executor;
+
+  @Before
+  public void setUp() throws Exception {
+    final Path inputDir = scratch.dir("/in");
+    inputRoot = Root.asDerivedRoot(inputDir);
+    outputRoot = Root.asDerivedRoot(scratch.dir("/out"));
+    outErr = new TestFileOutErr();
+    executor = new DummyExecutor(inputDir);
+  }
+
+  private ActionExecutionContext createContext() {
+    Path execRoot = executor.getExecRoot();
+    return new ActionExecutionContext(
+        executor,
+        new SingleBuildFileCache(execRoot.getPathString(), execRoot.getFileSystem()),
+        null, outErr, null);
+  }
+
+  @Test
+  public void testSimple() throws Exception {
+    Path inputFile = inputRoot.getPath().getChild("some-file");
+    Path outputFile = outputRoot.getPath().getChild("some-output");
+    FileSystemUtils.createEmptyFile(inputFile);
+    inputFile.setExecutable(/*executable=*/true);
+    Artifact input = new Artifact(inputFile, inputRoot);
+    Artifact output = new Artifact(outputFile, outputRoot);
+    ExecutableSymlinkAction action = new ExecutableSymlinkAction(NULL_ACTION_OWNER, input, output);
+    action.execute(createContext());
+    assertEquals(inputFile, outputFile.resolveSymbolicLinks());
+  }
+
+  @Test
+  public void testFailIfInputIsNotAFile() throws Exception {
+    Path dir = inputRoot.getPath().getChild("some-dir");
+    FileSystemUtils.createDirectoryAndParents(dir);
+    Artifact input = new Artifact(dir, inputRoot);
+    Artifact output = new Artifact(outputRoot.getPath().getChild("some-output"), outputRoot);
+    ExecutableSymlinkAction action = new ExecutableSymlinkAction(NULL_ACTION_OWNER, input, output);
+    try {
+      action.execute(createContext());
+      fail();
+    } catch (ActionExecutionException e) {
+      assertTrue(e.getMessage().contains("'some-dir' is not a file"));
+    }
+  }
+
+  @Test
+  public void testFailIfInputIsNotExecutable() throws Exception {
+    Path file = inputRoot.getPath().getChild("some-file");
+    FileSystemUtils.createEmptyFile(file);
+    file.setExecutable(/*executable=*/false);
+    Artifact input = new Artifact(file, inputRoot);
+    Artifact output = new Artifact(outputRoot.getPath().getChild("some-output"), outputRoot);
+    ExecutableSymlinkAction action = new ExecutableSymlinkAction(NULL_ACTION_OWNER, input, output);
+    try {
+      action.execute(createContext());
+      fail();
+    } catch (ActionExecutionException e) {
+      String want = "'some-file' is not executable";
+      String got = e.getMessage();
+      assertTrue(String.format("got %s, want %s", got, want), got.contains(want));
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/FailActionTest.java b/src/test/java/com/google/devtools/build/lib/actions/FailActionTest.java
new file mode 100644
index 0000000..7838fca
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/FailActionTest.java
@@ -0,0 +1,81 @@
+// Copyright 2015 Google Inc. 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.actions;
+
+
+import static com.google.devtools.build.lib.actions.util.ActionsTestUtil.NULL_ACTION_OWNER;
+import static com.google.devtools.build.lib.testutil.MoreAsserts.assertSameContents;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.devtools.build.lib.testutil.Scratch;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Collection;
+import java.util.Collections;
+
+@RunWith(JUnit4.class)
+public class FailActionTest {
+
+  private Scratch scratch = new Scratch();
+
+  private String errorMessage;
+  private Artifact anOutput;
+  private Collection<Artifact> outputs;
+  private FailAction failAction;
+
+  protected MutableActionGraph actionGraph = new MapBasedActionGraph();
+
+  @Before
+  public void setUp() throws Exception {
+    errorMessage = "An error just happened.";
+    anOutput = new Artifact(scratch.file("/out/foo"),
+        Root.asDerivedRoot(scratch.dir("/"), scratch.dir("/out")));
+    outputs = ImmutableList.of(anOutput);
+    failAction = new FailAction(NULL_ACTION_OWNER, outputs, errorMessage);
+    actionGraph.registerAction(failAction);
+    assertSame(failAction, actionGraph.getGeneratingAction(anOutput));
+  }
+
+  @Test
+  public void testExecutingItYieldsExceptionWithErrorMessage() {
+    try {
+      failAction.execute(null);
+      fail();
+    } catch (ActionExecutionException e) {
+      assertEquals(errorMessage, e.getMessage());
+    }
+  }
+
+  @Test
+  public void testInputsAreEmptySet() {
+    assertSameContents(Collections.emptySet(), failAction.getInputs());
+  }
+
+  @Test
+  public void testRetainsItsOutputs() {
+    assertSameContents(outputs, failAction.getOutputs());
+  }
+
+  @Test
+  public void testPrimaryOutput() {
+    assertSame(anOutput, failAction.getPrimaryOutput());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/LocalHostCapacityTest.java b/src/test/java/com/google/devtools/build/lib/actions/LocalHostCapacityTest.java
new file mode 100644
index 0000000..5e2e1a8
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/LocalHostCapacityTest.java
@@ -0,0 +1,434 @@
+// Copyright 2015 Google Inc. 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.actions;
+
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import com.google.devtools.build.lib.util.Clock;
+import com.google.devtools.build.lib.util.StringUtilities;
+import com.google.devtools.build.lib.vfs.util.FsApparatus;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class LocalHostCapacityTest {
+
+  private FsApparatus scratch = FsApparatus.newNative();
+
+  @Test
+  public void testNonHyperthreadedMachine() throws Exception {
+    String cpuinfoContent = StringUtilities.joinLines(
+        "processor\t: 0",
+        "vendor_id\t: GenuineIntel",
+        "cpu family\t: 15",
+        "model\t\t: 4",
+        "model name\t:               Intel(R) Pentium(R) 4 CPU 3.40GHz",
+        "stepping\t: 10",
+        "cpu MHz\t\t: 3400.000",
+        "cache size\t: 2048 KB",
+        "fpu\t\t: yes",
+        "fpu_exception\t: yes",
+        "cpuid level\t: 5",
+        "wp\t\t: yes",
+        "flags\t\t: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca "
+            + "cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm "
+            + "syscall nx lm constant_tsc up pni monitor ds_cpl est cid cx16 "
+            + "xtpr lahf_lm",
+        "bogomips\t: 6803.83",
+        "clflush size\t: 64",
+        "cache_alignment\t: 128",
+        "address sizes\t: 36 bits physical, 48 bits virtual",
+        "power management:"
+        );
+    String cpuinfoFile =
+        scratch.file("test_cpuinfo_nonht", cpuinfoContent).getPathString();
+    String meminfoContent = StringUtilities.joinLines(
+        "MemTotal:      3091732 kB",
+        "MemFree:       2167344 kB",
+        "Buffers:         60644 kB",
+        "Cached:         509940 kB",
+        "SwapCached:          0 kB",
+        "Active:         636892 kB",
+        "Inactive:       212760 kB",
+        "HighTotal:           0 kB",
+        "HighFree:            0 kB",
+        "LowTotal:      3091732 kB",
+        "LowFree:       2167344 kB",
+        "SwapTotal:     9124880 kB",
+        "SwapFree:      9124880 kB",
+        "Dirty:               0 kB",
+        "Writeback:           0 kB",
+        "AnonPages:      279028 kB",
+        "Mapped:          54404 kB",
+        "Slab:            42820 kB",
+        "PageTables:       5184 kB",
+        "NFS_Unstable:        0 kB",
+        "Bounce:              0 kB",
+        "CommitLimit:  10670744 kB",
+        "Committed_AS:   665840 kB",
+        "VmallocTotal: 34359738367 kB",
+        "VmallocUsed:    300484 kB",
+        "VmallocChunk: 34359437307 kB",
+        "HugePages_Total:     0",
+        "HugePages_Free:      0",
+        "HugePages_Rsvd:      0",
+        "Hugepagesize:     2048 kB"
+        );
+    String stat1Content = StringUtilities.joinLines(
+        "cpu 29793342 260290 3479274 636259369 6683218 656426 714057 0",
+        "cpu0 29793342 260290 3479274 636259369 6683218 656426 714057 0",
+        "intr 2870488853 2486517107 3 0 0 2 0 5 0 0 0 0 0 3 0 74363716 " +
+        "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 " +
+        "0 0 52483586 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 973315 0 0 0 0 0 0 0 46 " +
+        "0 0 0 0 0 0 0 98792358 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 " +
+        "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 " +
+        "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 114339590 " +
+        "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 " +
+        "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 43019122 0 0 0 0 0",
+        "ctxt 15053799843",
+        "btime 1199289688",
+        "processes 25799993",
+        "procs_running 1",
+        "procs_blocked 0"
+        );
+    String stat2Content = StringUtilities.joinLines(
+        "cpu 29794509 260290 3479474 636287862 6683283 656450 714087 0 0",
+        "cpu0 29794509 260290 3479474 636287862 6683283 656450 714087 0 0",
+        "intr 2870488853 2486517107 3 0 0 2 0 5 0 0 0 0 0 3 0 74363716 " +
+        "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 " +
+        "0 0 52483586 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 973315 0 0 0 0 0 0 0 46 " +
+        "0 0 0 0 0 0 0 98792358 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 " +
+        "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 " +
+        "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 114339590 " +
+        "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 " +
+        "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 43019122 0 0 0 0 0",
+        "ctxt 15053799843",
+        "btime 1199289688",
+        "processes 25799993",
+        "procs_running 1",
+        "procs_blocked 0"
+        );
+    String meminfoFile =
+        scratch.file("test_meminfo_nonht", meminfoContent).getPathString();
+    String stat1File =
+      scratch.file("proc_stat_1", stat1Content).getPathString();
+    String stat2File =
+      scratch.file("proc_stat_2", stat2Content).getPathString();
+    assertEquals(1, LocalHostCapacity.getLogicalCpuCount(cpuinfoContent));
+    assertEquals(1, LocalHostCapacity.getPhysicalCpuCount(cpuinfoContent, 1));
+    assertEquals(1, LocalHostCapacity.getCoresPerCpu(cpuinfoContent));
+    ResourceSet capacity =
+        LocalHostCapacity.getLocalHostCapacity(cpuinfoFile, meminfoFile);
+    assertEquals(1.0, capacity.getCpuUsage(), 0.01);
+    assertEquals(3091.732, capacity.getMemoryMb(), 0.1); // +/- 0.1MB
+    LocalHostCapacity.setLocalHostCapacity(capacity);
+    assertSame(capacity, LocalHostCapacity.getLocalHostCapacity());
+    Clock mockedClock = new Clock() {
+      private int callCount = 0;
+
+      @Override
+      public long currentTimeMillis() {
+        throw new AssertionError("unexpected method call");
+      }
+
+      @Override
+      public long nanoTime() {
+        callCount++;
+        if (callCount == 1) {
+          return 0;
+        } else if (callCount == 2) {
+          return 100 * 1000000;
+        } else if (callCount == 3) {
+          return 200 * 1000000;
+        } else {
+          throw new AssertionError("unexpected method call");
+        }
+      }
+    };
+    LocalHostCapacity.FreeResources freeStats =
+      LocalHostCapacity.getFreeResources(mockedClock, meminfoFile, stat1File, null);
+    assertNotNull(freeStats);
+    assertEquals(2356.756, freeStats.getFreeMb(), 0.001);
+    assertEquals(0.0, freeStats.getAvgFreeCpu(), 0);
+    // The next call to the mock clock returns a timestamp as if 100 ms have passed.
+    assertTrue(freeStats.getReadingAge() > 50);
+    // Fake another 100 ms going by for the next call.
+    freeStats = LocalHostCapacity.getFreeResources(mockedClock, meminfoFile, stat2File, freeStats);
+    assertNotNull(freeStats);
+    assertEquals(2356.756, freeStats.getFreeMb(), 0.001);
+    assertTrue(freeStats.getInterval() > 100);
+    assertEquals(0.95, freeStats.getAvgFreeCpu(), 0.001);
+  }
+
+  @Test
+  public void testHyperthreadedMachine() throws Exception {
+    String cpuinfoContent = StringUtilities.joinLines(
+        "processor\t: 0",
+        "vendor_id\t: GenuineIntel",
+        "cpu family\t: 15",
+        "model\t\t: 4",
+        "model name\t:               Intel(R) Pentium(R) 4 CPU 3.40GHz",
+        "stepping\t: 1",
+        "cpu MHz\t\t: 3400.245",
+        "cache size\t: 1024 KB",
+        "physical id\t: 0",
+        "siblings\t: 2",
+        "core id\t\t: 0",
+        "cpu cores\t: 1",
+        "fpu\t\t: yes",
+        "fpu_exception\t: yes",
+        "cpuid level\t: 5",
+        "wp\t\t: yes",
+        "flags\t\t: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge "
+            + "mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm "
+            + "syscall lm constant_tsc pni monitor ds_cpl cid cx16 xtpr",
+        "bogomips\t: 6806.31",
+        "clflush size\t: 64",
+        "cache_alignment\t: 128",
+        "address sizes\t: 36 bits physical, 48 bits virtual",
+        "power management:",
+        "",
+        "processor\t: 1",
+        "vendor_id\t: GenuineIntel",
+        "cpu family\t: 15",
+        "model\t\t: 4",
+        "model name\t:               Intel(R) Pentium(R) 4 CPU 3.40GHz",
+        "stepping\t: 1",
+        "cpu MHz\t\t: 3400.245",
+        "cache size\t: 1024 KB",
+        "physical id\t: 0",
+        "siblings\t: 2",
+        "core id\t\t: 0",
+        "cpu cores\t: 1",
+        "fpu\t\t: yes",
+        "fpu_exception\t: yes",
+        "cpuid level\t: 5",
+        "wp\t\t: yes",
+        "flags\t\t: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge "
+            + "mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm "
+            + "syscall lm constant_tsc pni monitor ds_cpl cid cx16 xtpr",
+        "bogomips\t: 6800.76",
+        "clflush size\t: 64",
+        "cache_alignment\t: 128",
+        "address sizes\t: 36 bits physical, 48 bits virtual",
+        "power management:",
+        ""
+        );
+    String cpuinfoFile =
+        scratch.file("test_cpuinfo_ht", cpuinfoContent).getPathString();
+    String meminfoContent = StringUtilities.joinLines(
+        "MemTotal:      3092004 kB",
+        "MemFree:         26124 kB",
+        "Buffers:          3836 kB",
+        "Cached:          52400 kB",
+        "SwapCached:      68204 kB",
+        "Active:        2281464 kB",
+        "Inactive:       260908 kB",
+        "HighTotal:           0 kB",
+        "HighFree:            0 kB",
+        "LowTotal:      3092004 kB",
+        "LowFree:         26124 kB",
+        "SwapTotal:     9124880 kB",
+        "SwapFree:      8264920 kB",
+        "Dirty:             616 kB",
+        "Writeback:           0 kB",
+        "AnonPages:     2466336 kB",
+        "Mapped:          37576 kB",
+        "Slab:           483004 kB",
+        "PageTables:      11912 kB",
+        "NFS_Unstable:        0 kB",
+        "Bounce:              0 kB",
+        "CommitLimit:  10670880 kB",
+        "Committed_AS:  3627984 kB",
+        "VmallocTotal: 34359738367 kB",
+        "VmallocUsed:    300460 kB",
+        "VmallocChunk: 34359437307 kB",
+        "HugePages_Total:     0",
+        "HugePages_Free:      0",
+        "HugePages_Rsvd:      0",
+        "Hugepagesize:     2048 kB"
+        );
+    String meminfoFile =
+        scratch.file("test_meminfo_ht", meminfoContent).getPathString();
+    assertEquals(2, LocalHostCapacity.getLogicalCpuCount(cpuinfoContent));
+    assertEquals(1, LocalHostCapacity.getPhysicalCpuCount(cpuinfoContent, 2));
+    assertEquals(1, LocalHostCapacity.getCoresPerCpu(cpuinfoContent));
+    ResourceSet capacity =
+        LocalHostCapacity.getLocalHostCapacity(cpuinfoFile, meminfoFile);
+    assertEquals(1.2, capacity.getCpuUsage(), .0001);
+    assertEquals(3092.004, capacity.getMemoryMb(), 0.1); // +/- 0.1MB
+  }
+
+  @Test
+  public void testAMDMachine() throws Exception {
+    String cpuinfoContent = StringUtilities.joinLines(
+        "processor\t: 0",
+        "vendor_id\t: AuthenticAMD",
+        "cpu family\t: 15",
+        "model\t\t: 65",
+        "model name\t: Dual-Core AMD Opteron(tm) Processor 8214 HE",
+        "stepping\t: 2",
+        "cpu MHz\t\t: 2200.000",
+        "cache size\t: 1024 KB",
+        "physical id\t: 0",
+        "siblings\t: 2",
+        "core id\t\t: 0",
+        "cpu cores\t: 2",
+        "fpu\t\t: yes",
+        "fpu_exception\t: yes",
+        "cpuid level\t: 1",
+        "wp\t\t: yes",
+        "flags\t\t: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr "
+            + "pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall "
+            + "nx mmxext fxsr_opt rdtscp lm 3dnowext 3dnow pni cx16 lahf_lm "
+            + "cmp_legacy svm cr8_legacy",
+        "bogomips\t: 4425.84",
+        "TLB size\t: 1024 4K pages",
+        "clflush size\t: 64",
+        "cache_alignment\t: 64",
+        "address sizes\t: 40 bits physical, 48 bits virtual",
+        "power management: ts fid vid ttp tm stc",
+        "",
+        "processor\t: 1",
+        "vendor_id\t: AuthenticAMD",
+        "cpu family\t: 15",
+        "model\t\t: 65",
+        "model name\t: Dual-Core AMD Opteron(tm) Processor 8214 HE",
+        "stepping\t: 2",
+        "cpu MHz\t\t: 2200.000",
+        "cache size\t: 1024 KB",
+        "physical id\t: 0",
+        "siblings\t: 2",
+        "core id\t\t: 1",
+        "cpu cores\t: 2",
+        "fpu\t\t: yes",
+        "fpu_exception\t: yes",
+        "cpuid level\t: 1",
+        "wp\t\t: yes",
+        "flags\t\t: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr "
+            + "pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall "
+            + "nx mmxext fxsr_opt rdtscp lm 3dnowext 3dnow pni cx16 lahf_lm "
+            + "cmp_legacy svm cr8_legacy",
+        "bogomips\t: 4460.61",
+        "TLB size\t: 1024 4K pages",
+        "clflush size\t: 64",
+        "cache_alignment\t: 64",
+        "address sizes\t: 40 bits physical, 48 bits virtual",
+        "power management: ts fid vid ttp tm stc",
+        "",
+        "processor\t: 2",
+        "vendor_id\t: AuthenticAMD",
+        "cpu family\t: 15",
+        "model\t\t: 65",
+        "model name\t: Dual-Core AMD Opteron(tm) Processor 8214 HE",
+        "stepping\t: 2",
+        "cpu MHz\t\t: 2200.000",
+        "cache size\t: 1024 KB",
+        "physical id\t: 1",
+        "siblings\t: 2",
+        "core id\t\t: 0",
+        "cpu cores\t: 2",
+        "fpu\t\t: yes",
+        "fpu_exception\t: yes",
+        "cpuid level\t: 1",
+        "wp\t\t: yes",
+        "flags\t\t: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr "
+            + "pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall "
+            + "nx mmxext fxsr_opt rdtscp lm 3dnowext 3dnow pni cx16 lahf_lm "
+            + "cmp_legacy svm cr8_legacy",
+        "bogomips\t: 4420.45",
+        "TLB size\t: 1024 4K pages",
+        "clflush size\t: 64",
+        "cache_alignment\t: 64",
+        "address sizes\t: 40 bits physical, 48 bits virtual",
+        "power management: ts fid vid ttp tm stc",
+        "",
+        "processor\t: 3",
+        "vendor_id\t: AuthenticAMD",
+        "cpu family\t: 15",
+        "model\t\t: 65",
+        "model name\t: Dual-Core AMD Opteron(tm) Processor 8214 HE",
+        "stepping\t: 2",
+        "cpu MHz\t\t: 2200.000",
+        "cache size\t: 1024 KB",
+        "physical id\t: 1",
+        "siblings\t: 2",
+        "core id\t\t: 1",
+        "cpu cores\t: 2",
+        "fpu\t\t: yes",
+        "fpu_exception\t: yes",
+        "cpuid level\t: 1",
+        "wp\t\t: yes",
+        "flags\t\t: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr "
+            + "pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall "
+            + "nx mmxext fxsr_opt rdtscp lm 3dnowext 3dnow pni cx16 lahf_lm "
+            + "cmp_legacy svm cr8_legacy",
+        "bogomips\t: 4460.39",
+        "TLB size\t: 1024 4K pages",
+        "clflush size\t: 64",
+        "cache_alignment\t: 64",
+        "address sizes\t: 40 bits physical, 48 bits virtual",
+        "power management: ts fid vid ttp tm stc",
+        ""
+        );
+    String cpuinfoFile =
+        scratch.file("test_cpuinfo_amd", cpuinfoContent).getPathString();
+    String meminfoContent = StringUtilities.joinLines(
+        "MemTotal:      8223956 kB",
+        "MemFree:       3670396 kB",
+        "Buffers:        374068 kB",
+        "Cached:        3366980 kB",
+        "SwapCached:          0 kB",
+        "Active:        3275860 kB",
+        "Inactive:       737816 kB",
+        "HighTotal:           0 kB",
+        "HighFree:            0 kB",
+        "LowTotal:      8223956 kB",
+        "LowFree:       3670396 kB",
+        "SwapTotal:     6024332 kB",
+        "SwapFree:      6024332 kB",
+        "Dirty:              84 kB",
+        "Writeback:           0 kB",
+        "AnonPages:      272308 kB",
+        "Mapped:          62604 kB",
+        "Slab:           506140 kB",
+        "PageTables:       4608 kB",
+        "NFS_Unstable:        0 kB",
+        "Bounce:              0 kB",
+        "CommitLimit:  10136308 kB",
+        "Committed_AS:   600672 kB",
+        "VmallocTotal: 34359738367 kB",
+        "VmallocUsed:    299068 kB",
+        "VmallocChunk: 34359438843 kB",
+        "HugePages_Total:     0",
+        "HugePages_Free:      0",
+        "HugePages_Rsvd:      0",
+        "Hugepagesize:     2048 kB");
+    String meminfoFile =
+        scratch.file("test_meminfo_amd", meminfoContent).getPathString();
+    assertEquals(4, LocalHostCapacity.getLogicalCpuCount(cpuinfoContent));
+    assertEquals(2, LocalHostCapacity.getPhysicalCpuCount(cpuinfoContent, 4));
+    assertEquals(2, LocalHostCapacity.getCoresPerCpu(cpuinfoContent));
+    ResourceSet capacity =
+        LocalHostCapacity.getLocalHostCapacity(cpuinfoFile, meminfoFile);
+    assertEquals(capacity.getCpuUsage(), 4.0, 0.01);
+    assertEquals(8223.956, capacity.getMemoryMb(), 0.1); // +/- 0.1MB
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/MapBasedActionGraphTest.java b/src/test/java/com/google/devtools/build/lib/actions/MapBasedActionGraphTest.java
new file mode 100644
index 0000000..cde9aed3
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/MapBasedActionGraphTest.java
@@ -0,0 +1,147 @@
+// Copyright 2015 Google Inc. 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.actions;
+
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.devtools.build.lib.actions.MutableActionGraph.ActionConflictException;
+import com.google.devtools.build.lib.actions.util.ActionsTestUtil.UncheckedActionConflictException;
+import com.google.devtools.build.lib.actions.util.TestAction;
+import com.google.devtools.build.lib.concurrent.AbstractQueueVisitor;
+import com.google.devtools.build.lib.util.BlazeClock;
+import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Tests for {@link MapBasedActionGraph}.
+ */
+@RunWith(JUnit4.class)
+public class MapBasedActionGraphTest {
+  @Test
+  public void testSmoke() throws Exception {
+    MutableActionGraph actionGraph = new MapBasedActionGraph();
+    FileSystem fileSystem = new InMemoryFileSystem(BlazeClock.instance());
+    Path path = fileSystem.getPath("/root/foo");
+    Artifact output = new Artifact(path, Root.asDerivedRoot(path));
+    Action action = new TestAction(TestAction.NO_EFFECT,
+        ImmutableSet.<Artifact>of(), ImmutableSet.of(output));
+    actionGraph.registerAction(action);
+    actionGraph.unregisterAction(action);
+    path = fileSystem.getPath("/root/bar");
+    output = new Artifact(path, Root.asDerivedRoot(path));
+    Action action2 = new TestAction(TestAction.NO_EFFECT,
+        ImmutableSet.<Artifact>of(), ImmutableSet.of(output));
+    actionGraph.registerAction(action);
+    actionGraph.registerAction(action2);
+    actionGraph.unregisterAction(action);
+  }
+
+  @Test
+  public void testNoActionConflictWhenUnregisteringSharedAction() throws Exception {
+    MutableActionGraph actionGraph = new MapBasedActionGraph();
+    FileSystem fileSystem = new InMemoryFileSystem(BlazeClock.instance());
+    Path path = fileSystem.getPath("/root/foo");
+    Artifact output = new Artifact(path, Root.asDerivedRoot(path));
+    Action action = new TestAction(TestAction.NO_EFFECT,
+        ImmutableSet.<Artifact>of(), ImmutableSet.of(output));
+    actionGraph.registerAction(action);
+    Action otherAction = new TestAction(TestAction.NO_EFFECT,
+        ImmutableSet.<Artifact>of(), ImmutableSet.of(output));
+    actionGraph.registerAction(otherAction);
+    actionGraph.unregisterAction(action);
+  }
+
+  private class ActionRegisterer extends AbstractQueueVisitor {
+    private final MutableActionGraph graph = new MapBasedActionGraph();
+    private final Artifact output;
+    // Just to occasionally add actions that were already present.
+    private final Set<Action> allActions = Sets.newConcurrentHashSet();
+    private final AtomicInteger actionCount = new AtomicInteger(0);
+
+    private ActionRegisterer() {
+      super(/*concurrent=*/true, 200, 200, 1, TimeUnit.SECONDS,
+          /*failFastOnException=*/true, /*failFastOnInterrupt=*/true, "action-graph-test");
+      FileSystem fileSystem = new InMemoryFileSystem(BlazeClock.instance());
+      Path path = fileSystem.getPath("/root/foo");
+      output = new Artifact(path, Root.asDerivedRoot(path));
+      allActions.add(new TestAction(TestAction.NO_EFFECT,
+          ImmutableSet.<Artifact>of(), ImmutableSet.of(output)));
+    }
+
+    private void registerAction(final Action action) {
+      enqueue(new Runnable() {
+        @Override
+        public void run() {
+          try {
+            graph.registerAction(action);
+          } catch (ActionConflictException e) {
+            throw new UncheckedActionConflictException(e);
+          }
+          doRandom();
+        }
+      });
+    }
+
+    private void unregisterAction(final Action action) {
+      enqueue(new Runnable() {
+        @Override
+        public void run() {
+          graph.unregisterAction(action);
+          doRandom();
+        }
+      });
+    }
+
+    private void doRandom() {
+      if (actionCount.incrementAndGet() > 10000) {
+        return;
+      }
+      Action action = null;
+      if (Math.random() < 0.5) {
+        action = Iterables.getFirst(allActions, null);
+      } else {
+        action = new TestAction(TestAction.NO_EFFECT,
+            ImmutableSet.<Artifact>of(), ImmutableSet.of(output));
+        allActions.add(action);
+      }
+      if (Math.random() < 0.5) {
+        registerAction(action);
+      } else {
+        unregisterAction(action);
+      }
+    }
+
+    private void work() throws InterruptedException {
+      work(/*failFastOnInterrupt=*/true);
+    }
+  }
+
+  @Test
+  public void testSharedActionStressTest() throws Exception {
+    ActionRegisterer actionRegisterer = new ActionRegisterer();
+    actionRegisterer.doRandom();
+    actionRegisterer.work();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/ResourceManagerTest.java b/src/test/java/com/google/devtools/build/lib/actions/ResourceManagerTest.java
new file mode 100644
index 0000000..4955288
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/ResourceManagerTest.java
@@ -0,0 +1,400 @@
+// Copyright 2015 Google Inc. 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.actions;
+
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.eventbus.EventBus;
+import com.google.devtools.build.lib.testutil.TestThread;
+import com.google.devtools.common.options.OptionsParsingException;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import javax.annotation.Nullable;
+
+/**
+ *
+ * Tests for @{link ResourceManager}.
+ */
+@RunWith(JUnit4.class)
+public class ResourceManagerTest {
+
+  private final ActionMetadata resourceOwner = new ResourceOwnerStub();
+  private final ResourceManager rm = ResourceManager.instanceForTestingOnly();
+  private AtomicInteger counter;
+  CyclicBarrier sync;
+  CyclicBarrier sync2;
+
+  @Before
+  public void setUp() throws Exception {
+    rm.setRamUtilizationPercentage(100);
+    rm.setAvailableResources(new ResourceSet(1000, 1, 1));
+    rm.setEventBus(new EventBus());
+    counter = new AtomicInteger(0);
+    sync = new CyclicBarrier(2);
+    sync2 = new CyclicBarrier(2);
+    rm.resetResourceUsage();
+  }
+
+  private void acquire(double ram, double cpu, double io) throws InterruptedException {
+    rm.acquireResources(resourceOwner, new ResourceSet(ram, cpu, io));
+  }
+
+  private boolean acquireNonblocking(double ram, double cpu, double io) {
+    return rm.tryAcquire(resourceOwner, new ResourceSet(ram, cpu, io));
+  }
+
+  private void release(double ram, double cpu, double io) {
+    rm.releaseResources(resourceOwner, new ResourceSet(ram, cpu, io));
+  }
+
+  private void validate (int count) {
+    assertEquals(count, counter.incrementAndGet());
+  }
+
+  @Test
+  public void testIndependentLargeRequests() throws Exception {
+    // Available: 1000 RAM and 1 CPU.
+    assertFalse(rm.inUse());
+    acquire(10000, 0, 0); // Available: 0 RAM 1 CPU 1 IO.
+    acquire(0, 100, 0);   // Available: 0 RAM 0 CPU 1 IO.
+    acquire(0, 0, 1);     // Available: 0 RAM 0 CPU 0 IO.
+    assertTrue(rm.inUse());
+    release(9500, 0, 0);  // Available: 500 RAM 0 CPU 0 IO.
+    acquire(400, 0, 0);   // Available: 100 RAM 0 CPU 0 IO.
+    release(0, 99.5, 0.6);  // Available: 100 RAM 0.5 CPU 0.4 IO.
+    acquire(100, 0.5, 0.4); // Available: 0 RAM 0 CPU 0 IO.
+    release(1000, 1, 1);  // Available: 1000 RAM 1 CPU 1 IO.
+    assertFalse(rm.inUse());
+  }
+
+  @Test
+  public void testOverallocation() throws Exception {
+    // Since ResourceManager.MIN_NECESSARY_RAM_RATIO = 1.0, overallocation is
+    // enabled only for the CPU resource.
+    assertFalse(rm.inUse());
+    acquire(900, 0.5, 0.1);  // Available: 100 RAM 0.5 CPU 0.9 IO.
+    acquire(100, 0.6, 0.9);  // Available: 0 RAM 0 CPU 0 IO.
+    release(100, 0.6, 0.9);  // Available: 100 RAM 0.5 CPU 0.9 IO.
+    acquire(100, 0.1, 0.1);  // Available: 0 RAM 0.4 CPU 0.8 IO.
+    acquire(0, 0.5, 0.8);    // Available: 0 RAM 0 CPU 0.8 IO.
+    release(1020, 1.1, 1.05); // Available: 1000 RAM 1 CPU 1 IO.
+    assertFalse(rm.inUse());
+  }
+
+  @Test
+  public void testNonblocking() throws Exception {
+    assertFalse(rm.inUse());
+    assertTrue(acquireNonblocking(900, 0.5, 0));  // Available: 100 RAM 0.5 CPU 1 IO.
+    assertTrue(acquireNonblocking(100, 0.5, 0.2));  // Available: 0 RAM 0 CPU 0.8 IO.
+    assertFalse(acquireNonblocking(.1, .01, 0.0));
+    assertFalse(acquireNonblocking(0, 0, 0.9));
+    assertTrue(acquireNonblocking(0, 0, 0.8));  // Available: 0 RAM 0 CPU 0 IO.
+    release(100, 0.5, 0.1);  // Available: 100 RAM 0.5 CPU 0.1 IO.
+    assertTrue(acquireNonblocking(100, 0.1, 0.1));  // Available: 0 RAM 0.4 CPU 0 IO.
+    assertFalse(acquireNonblocking(5, .5, 0));
+    assertFalse(acquireNonblocking(0, .5, 0.1));
+    assertTrue(acquireNonblocking(0, 0.4, 0));    // Available: 0 RAM 0 CPU 0 IO.
+    release(1000, 1, 1); // Available: 1000 RAM 1 CPU 1 IO.
+    assertFalse(rm.inUse());
+  }
+
+  @Test
+  public void testHasResources() throws Exception {
+    assertFalse(rm.inUse());
+    assertFalse(rm.threadHasResources());
+    acquire(1, .1, .1);
+    assertTrue(rm.threadHasResources());
+
+    // We have resources in this thread - make sure other threads
+    // are not affected.
+    TestThread thread1 = new TestThread () {
+      @Override public void runTest() throws Exception {
+        assertFalse(rm.threadHasResources());
+        acquire(1, 0, 0);
+        assertTrue(rm.threadHasResources());
+        release(1, 0, 0);
+        assertFalse(rm.threadHasResources());
+        acquire(0, 0.1, 0);
+        assertTrue(rm.threadHasResources());
+        release(0, 0.1, 0);
+        assertFalse(rm.threadHasResources());
+        acquire(0, 0, 0.1);
+        assertTrue(rm.threadHasResources());
+        release(0, 0, 0.1);
+        assertFalse(rm.threadHasResources());
+      }
+    };
+    thread1.start();
+    thread1.joinAndAssertState(10000);
+
+    release(1, .1, .1);
+    assertFalse(rm.threadHasResources());
+    assertFalse(rm.inUse());
+  }
+
+  @Test
+  public void testConcurrentLargeRequests() throws Exception {
+    assertFalse(rm.inUse());
+    TestThread thread1 = new TestThread () {
+      @Override public void runTest() throws Exception {
+        acquire(2000, 2, 0);
+        sync.await();
+        validate(1);
+        sync.await();
+        // Wait till other thread will be locked.
+        while (rm.getWaitCount() == 0) {
+          Thread.yield();
+        }
+        release(2000, 2, 0);
+        assertEquals(0, rm.getWaitCount());
+        acquire(2000, 2, 0); // Will be blocked by the thread2.
+        validate(3);
+        release(2000, 2, 0);
+      }
+    };
+    TestThread thread2 = new TestThread () {
+      @Override public void runTest() throws Exception {
+        sync2.await();
+        assertFalse(rm.isAvailable(2000, 2, 0));
+        acquire(2000, 2, 0); // Will be blocked by the thread1.
+        validate(2);
+        sync2.await();
+        // Wait till other thread will be locked.
+        while (rm.getWaitCount() == 0) {
+          Thread.yield();
+        }
+        release(2000, 2, 0);
+      }
+    };
+
+    thread1.start();
+    thread2.start();
+    sync.await(1, TimeUnit.SECONDS);
+    assertTrue(rm.inUse());
+    assertEquals(0, rm.getWaitCount());
+    sync2.await(1, TimeUnit.SECONDS);
+    sync.await(1, TimeUnit.SECONDS);
+    sync2.await(1, TimeUnit.SECONDS);
+    thread1.joinAndAssertState(1000);
+    thread2.joinAndAssertState(1000);
+    assertFalse(rm.inUse());
+  }
+
+  @Test
+  public void testOutOfOrderAllocation() throws Exception {
+    assertFalse(rm.inUse());
+    TestThread thread1 = new TestThread () {
+      @Override public void runTest() throws Exception {
+        sync.await();
+        acquire(900, 0.5, 0); // Will be blocked by the main thread.
+        validate(5);
+        release(900, 0.5, 0);
+        sync.await();
+      }
+    };
+    TestThread thread2 = new TestThread() {
+      @Override public void runTest() throws Exception {
+        // Wait till other thread will be locked
+        while (rm.getWaitCount() == 0) {
+          Thread.yield();
+        }
+        acquire(100, 0.1, 0);
+        validate(2);
+        release(100, 0.1, 0);
+        sync2.await();
+        acquire(200, 0.5, 0);
+        validate(4);
+        sync2.await();
+        release(200, 0.5, 0);
+      }
+    };
+    acquire(900, 0.9, 0);
+    validate(1);
+    thread1.start();
+    sync.await(1, TimeUnit.SECONDS);
+    thread2.start();
+    sync2.await(1, TimeUnit.SECONDS);
+    //Waiting till both threads are locked.
+    while (rm.getWaitCount() < 2) {
+      Thread.yield();
+    }
+    validate(3); // Thread1 is now first in the queue and Thread2 is second.
+    release(100, 0.4, 0); // This allows Thread2 to continue out of order.
+    sync2.await(1, TimeUnit.SECONDS);
+    release(750, 0.3, 0); // At this point thread1 will finally acquire resources.
+    sync.await(1, TimeUnit.SECONDS);
+    release(50, 0.2, 0);
+    thread1.join();
+    thread2.join();
+    assertFalse(rm.inUse());
+  }
+
+  @Test
+  public void testSingleton() throws Exception {
+    ResourceManager.instance();
+  }
+
+  /**
+   * Checks that that resource manager
+   * can recover from LocalHostCapacity.getFreeResources() failure.
+   */
+  @Test
+  public void testAutoSenseFailure() throws Exception {
+    boolean isDisabled = LocalHostCapacity.isDisabled;
+    assertFalse(rm.inUse());
+    try {
+      rm.setAutoSensing(true);
+      // Resource manager autosense state should be enabled now if
+      // LocalHostCapacity class supports it.
+      assertEquals(rm.isAutoSensingEnabled(), !LocalHostCapacity.isDisabled);
+      rm.setAutoSensing(false);
+      assertFalse(rm.isAutoSensingEnabled());
+
+      // Emulate failure to parse /proc/* filesystem.
+      LocalHostCapacity.isDisabled = true;
+      rm.setAutoSensing(true);
+      assertFalse(rm.isAutoSensingEnabled());
+      rm.setAutoSensing(false);
+      assertFalse(rm.isAutoSensingEnabled());
+    } finally {
+      LocalHostCapacity.isDisabled = isDisabled;
+      rm.setAutoSensing(false);
+    }
+    assertFalse(rm.inUse());
+  }
+
+  @Test
+  public void testResourceSetConverter() throws Exception {
+    ResourceSet.ResourceSetConverter converter = new ResourceSet.ResourceSetConverter();
+
+    ResourceSet resources = converter.convert("1,0.5,2");
+    assertEquals(1.0, resources.getMemoryMb(), 0.01);
+    assertEquals(0.5, resources.getCpuUsage(), 0.01);
+    assertEquals(2.0, resources.getIoUsage(), 0.01);
+
+    try {
+      converter.convert("0,0,");
+      fail();
+    } catch (OptionsParsingException ope) {
+      // expected
+    }
+
+    try {
+      converter.convert("0,0,0,0");
+      fail();
+    } catch (OptionsParsingException ope) {
+      // expected
+    }
+
+    try {
+      converter.convert("-1,0,0");
+      fail();
+    } catch (OptionsParsingException ope) {
+      // expected
+    }
+  }
+
+  private static class ResourceOwnerStub implements ActionMetadata {
+
+    @Override
+    @Nullable
+    public String getProgressMessage() {
+      throw new IllegalStateException();
+    }
+
+    @Override
+    public ActionOwner getOwner() {
+      throw new IllegalStateException();
+    }
+
+    @Override
+    public String prettyPrint() {
+      throw new IllegalStateException();
+    }
+
+    @Override
+    public String getMnemonic() {
+      throw new IllegalStateException();
+    }
+
+    @Override
+    public String describeStrategy(Executor executor) {
+      throw new IllegalStateException();
+    }
+
+    @Override
+    public boolean inputsKnown() {
+      throw new IllegalStateException();
+    }
+
+    @Override
+    public boolean discoversInputs() {
+      throw new IllegalStateException();
+    }
+
+    @Override
+    public Iterable<Artifact> getInputs() {
+      throw new IllegalStateException();
+    }
+
+    @Override
+    public int getInputCount() {
+      throw new IllegalStateException();
+    }
+
+    @Override
+    public ImmutableSet<Artifact> getOutputs() {
+      throw new IllegalStateException();
+    }
+
+    @Override
+    public Artifact getPrimaryInput() {
+      throw new IllegalStateException();
+    }
+
+    @Override
+    public Artifact getPrimaryOutput() {
+      throw new IllegalStateException();
+    }
+
+    @Override
+    public Iterable<Artifact> getMandatoryInputs() {
+      throw new IllegalStateException();
+    }
+
+    @Override
+    public String getKey() {
+      throw new IllegalStateException();
+    }
+
+    @Override
+    @Nullable
+    public String describeKey() {
+      throw new IllegalStateException();
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/RootTest.java b/src/test/java/com/google/devtools/build/lib/actions/RootTest.java
new file mode 100644
index 0000000..c8fc14b
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/RootTest.java
@@ -0,0 +1,132 @@
+// Copyright 2015 Google Inc. 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.actions;
+
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.devtools.build.lib.testutil.Scratch;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.IOException;
+
+/**
+ * Tests for {@link Root}.
+ */
+@RunWith(JUnit4.class)
+public class RootTest {
+  private Scratch scratch = new Scratch();
+
+  @Test
+  public void testAsSourceRoot() throws IOException {
+    Path sourceDir = scratch.dir("/source");
+    Root root = Root.asSourceRoot(sourceDir);
+    assertTrue(root.isSourceRoot());
+    assertEquals(PathFragment.EMPTY_FRAGMENT, root.getExecPath());
+    assertEquals(sourceDir, root.getPath());
+    assertEquals("/source[source]", root.toString());
+  }
+
+  @Test
+  public void testBadAsSourceRoot() {
+    try {
+      Root.asSourceRoot(null);
+      fail();
+    } catch (NullPointerException expected) {
+    }
+  }
+
+  @Test
+  public void testAsDerivedRoot() throws IOException {
+    Path execRoot = scratch.dir("/exec");
+    Path rootDir = scratch.dir("/exec/root");
+    Root root = Root.asDerivedRoot(execRoot, rootDir);
+    assertFalse(root.isSourceRoot());
+    assertEquals(new PathFragment("root"), root.getExecPath());
+    assertEquals(rootDir, root.getPath());
+    assertEquals("/exec/root[derived]", root.toString());
+  }
+
+  @Test
+  public void testBadAsDerivedRoot() throws IOException {
+    try {
+      Path execRoot = scratch.dir("/exec");
+      Path outsideDir = scratch.dir("/not_exec");
+      Root.asDerivedRoot(execRoot, outsideDir);
+      fail();
+    } catch (IllegalArgumentException expected) {
+    }
+  }
+
+  @Test
+  public void testBadAsDerivedRootSameForBoth() throws IOException {
+    try {
+      Path execRoot = scratch.dir("/exec");
+      Root.asDerivedRoot(execRoot, execRoot);
+      fail();
+    } catch (IllegalArgumentException expected) {
+    }
+  }
+
+  @Test
+  public void testBadAsDerivedRootNullDir() throws IOException {
+    try {
+      Path execRoot = scratch.dir("/exec");
+      Root.asDerivedRoot(execRoot, null);
+      fail();
+    } catch (NullPointerException expected) {
+    }
+  }
+
+  @Test
+  public void testBadAsDerivedRootNullExecRoot() throws IOException {
+    try {
+      Path execRoot = scratch.dir("/exec");
+      Root.asDerivedRoot(null, execRoot);
+      fail();
+    } catch (NullPointerException expected) {
+    }
+  }
+
+  @Test
+  public void testEquals() throws IOException {
+    Path execRoot = scratch.dir("/exec");
+    Path rootDir = scratch.dir("/exec/root");
+    Path otherRootDir = scratch.dir("/");
+    Path sourceDir = scratch.dir("/source");
+    Root rootA = Root.asDerivedRoot(execRoot, rootDir);
+    assertEqualsAndHashCode(true, rootA, Root.asDerivedRoot(execRoot, rootDir));
+    assertEqualsAndHashCode(false, rootA, Root.asSourceRoot(sourceDir));
+    assertEqualsAndHashCode(false, rootA, Root.asSourceRoot(rootDir));
+    assertEqualsAndHashCode(false, rootA, Root.asDerivedRoot(otherRootDir, rootDir));
+  }
+
+  public void assertEqualsAndHashCode(boolean expected, Object a, Object b) {
+    if (expected) {
+      assertTrue(a.equals(b));
+      assertTrue(a.hashCode() == b.hashCode());
+    } else {
+      assertFalse(a.equals(b));
+      assertFalse(a.hashCode() == b.hashCode());
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/cache/CompactPersistentActionCacheTest.java b/src/test/java/com/google/devtools/build/lib/actions/cache/CompactPersistentActionCacheTest.java
new file mode 100644
index 0000000..5fc1f4f
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/cache/CompactPersistentActionCacheTest.java
@@ -0,0 +1,190 @@
+// Copyright 2015 Google Inc. 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.actions.cache;
+
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.google.devtools.build.lib.util.Clock;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.build.lib.vfs.util.FsApparatus;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.IOException;
+
+/**
+ * Test for the CompactPersistentActionCache class.
+ */
+@RunWith(JUnit4.class)
+public class CompactPersistentActionCacheTest {
+
+  private static class ManualClock implements Clock {
+    private long currentTime = 0L;
+
+    ManualClock() { }
+
+    @Override public long currentTimeMillis() {
+      return currentTime;
+    }
+
+    @Override public long nanoTime() {
+      return 0;
+    }
+  }
+
+  private FsApparatus scratch = FsApparatus.newInMemory();
+  private Path dataRoot;
+  private Path mapFile;
+  private Path journalFile;
+  private ManualClock clock = new ManualClock();
+  private CompactPersistentActionCache cache;
+
+  @Before
+  public void setUp() throws Exception {
+    dataRoot = scratch.path("/cache/test.dat");
+    cache = new CompactPersistentActionCache(dataRoot, clock);
+    mapFile = CompactPersistentActionCache.cacheFile(dataRoot);
+    journalFile = CompactPersistentActionCache.journalFile(dataRoot);
+  }
+
+  @Test
+  public void testGetInvalidKey() {
+    assertNull(cache.get("key"));
+  }
+
+  @Test
+  public void testPutAndGet() {
+    String key = "key";
+    putKey(key);
+    ActionCache.Entry readentry = cache.get(key);
+    assertTrue(readentry != null);
+    assertEquals(cache.get(key).toString(), readentry.toString());
+    assertFalse(mapFile.exists());
+  }
+
+  @Test
+  public void testPutAndRemove() {
+    String key = "key";
+    putKey(key);
+    cache.remove(key);
+    assertNull(cache.get(key));
+    assertFalse(mapFile.exists());
+  }
+
+  @Test
+  public void testSave() throws IOException {
+    String key = "key";
+    putKey(key);
+    cache.save();
+    assertTrue(mapFile.exists());
+    assertFalse(journalFile.exists());
+
+    CompactPersistentActionCache newcache =
+      new CompactPersistentActionCache(dataRoot, clock);
+    ActionCache.Entry readentry = newcache.get(key);
+    assertTrue(readentry != null);
+    assertEquals(cache.get(key).toString(), readentry.toString());
+  }
+
+  @Test
+  public void testIncrementalSave() throws IOException {
+    for (int i = 0; i < 300; i++) {
+      putKey(Integer.toString(i));
+    }
+    assertFullSave();
+
+    // Add 2 entries to 300. Might as well just leave them in the journal.
+    putKey("abc");
+    putKey("123");
+    assertIncrementalSave(cache);
+
+    // Make sure we have all the entries, including those in the journal,
+    // after deserializing into a new cache.
+    CompactPersistentActionCache newcache =
+        new CompactPersistentActionCache(dataRoot, clock);
+    for (int i = 0; i < 100; i++) {
+      assertKeyEquals(cache, newcache, Integer.toString(i));
+    }
+    assertKeyEquals(cache, newcache, "abc");
+    assertKeyEquals(cache, newcache, "123");
+    putKey("xyz", newcache);
+    assertIncrementalSave(newcache);
+
+    // Make sure we can see previous journal values after a second incremental save.
+    CompactPersistentActionCache newerCache =
+        new CompactPersistentActionCache(dataRoot, clock);
+    for (int i = 0; i < 100; i++) {
+      assertKeyEquals(cache, newerCache, Integer.toString(i));
+    }
+    assertKeyEquals(cache, newerCache, "abc");
+    assertKeyEquals(cache, newerCache, "123");
+    assertNotNull(newerCache.get("xyz"));
+    assertNull(newerCache.get("not_a_key"));
+
+    // Add another 10 entries. This should not be incremental.
+    for (int i = 300; i < 310; i++) {
+      putKey(Integer.toString(i));
+    }
+    assertFullSave();
+  }
+
+  // Regression test to check that CompactActionCacheEntry.toString does not mutate the object.
+  // Mutations may result in IllegalStateException.
+  @Test
+  public void testEntryToStringIsIdempotent() throws Exception {
+    ActionCache.Entry entry = new ActionCache.Entry("actionKey");
+    entry.toString();
+    entry.addFile(new PathFragment("foo/bar"), Metadata.CONSTANT_METADATA);
+    entry.toString();
+    entry.getFileDigest();
+    entry.toString();
+  }
+
+  private static void assertKeyEquals(ActionCache cache1, ActionCache cache2, String key) {
+    Object entry = cache1.get(key);
+    assertNotNull(entry);
+    assertEquals(entry.toString(), cache2.get(key).toString());
+  }
+
+  private void assertFullSave() throws IOException {
+    cache.save();
+    assertTrue(mapFile.exists());
+    assertFalse(journalFile.exists());
+  }
+
+  private void assertIncrementalSave(ActionCache ac) throws IOException {
+    ac.save();
+    assertTrue(mapFile.exists());
+    assertTrue(journalFile.exists());
+  }
+
+  private void putKey(String key) {
+    putKey(key, cache);
+  }
+
+  private void putKey(String key, ActionCache ac) {
+    ActionCache.Entry entry = ac.createEntry(key);
+    entry.getFileDigest();
+    ac.put(key, entry);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/cache/MetadataTest.java b/src/test/java/com/google/devtools/build/lib/actions/cache/MetadataTest.java
new file mode 100644
index 0000000..fe37af2
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/cache/MetadataTest.java
@@ -0,0 +1,45 @@
+// Copyright 2015 Google Inc. 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.actions.cache;
+
+
+import com.google.common.io.BaseEncoding;
+import com.google.common.testing.EqualsTester;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class MetadataTest {
+
+  private static byte[] toBytes(String hex) {
+    return BaseEncoding.base16().upperCase().decode(hex);
+  }
+
+  @Test
+  public void testEqualsAndHashCode() throws Exception {
+    // Each "equality group" is checked for equality within itself (including hashCode equality)
+    // and inequality with members of other equality groups.
+    new EqualsTester()
+        .addEqualityGroup(new Metadata(toBytes("00112233445566778899AABBCCDDEEFF")),
+                          new Metadata(toBytes("00112233445566778899AABBCCDDEEFF")))
+        .addEqualityGroup(new Metadata(1))
+        .addEqualityGroup(new Metadata(toBytes("FFFFFF00000000000000000000000000")))
+        .addEqualityGroup(new Metadata(2),
+                          new Metadata(2))
+        .addEqualityGroup("a string")
+        .testEquals();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/cache/PersistentStringIndexerTest.java b/src/test/java/com/google/devtools/build/lib/actions/cache/PersistentStringIndexerTest.java
new file mode 100644
index 0000000..7fc0cb1
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/cache/PersistentStringIndexerTest.java
@@ -0,0 +1,387 @@
+// Copyright 2015 Google Inc. 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.actions.cache;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.devtools.build.lib.testutil.TestThread;
+import com.google.devtools.build.lib.util.Clock;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.util.FsApparatus;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+
+/**
+ * Test for the PersistentStringIndexer class.
+ */
+@RunWith(JUnit4.class)
+public class PersistentStringIndexerTest {
+
+  private static class ManualClock implements Clock {
+    private long currentTime = 0L;
+
+    ManualClock() { }
+
+    @Override public long currentTimeMillis() {
+      throw new AssertionError("unexpected method call");
+    }
+
+    @Override  public long nanoTime() {
+      return currentTime;
+    }
+
+    void advance(long time) {
+      currentTime += time;
+    }
+  }
+
+  private PersistentStringIndexer psi;
+  private Map<Integer, String> mappings = new ConcurrentHashMap<>();
+  private FsApparatus scratch = FsApparatus.newInMemory();
+  private ManualClock clock = new ManualClock();
+  private Path dataPath;
+  private Path journalPath;
+
+
+  @Before
+  public void setUp() throws Exception {
+    dataPath = scratch.path("/cache/test.dat");
+    journalPath = scratch.path("/cache/test.journal");
+    psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock);
+  }
+
+  private void assertSize(int expected) {
+    assertEquals(expected, psi.size());
+  }
+
+  private void assertIndex(int expected, String s) {
+    int index = psi.getOrCreateIndex(s);
+    assertEquals(expected, index);
+    mappings.put(expected, s);
+  }
+
+  private void assertContent() {
+    for (int i = 0; i < psi.size(); i++) {
+      if(mappings.get(i) != null) {
+        assertEquals(mappings.get(i), psi.getStringForIndex(i));
+      }
+    }
+  }
+
+
+  private void setupTestContent() {
+    assertSize(0);
+    assertIndex(0, "abcdefghi");  // Create leafs
+    assertIndex(1, "abcdefjkl");
+    assertIndex(2, "abcdefmno");
+    assertIndex(3, "abcdefjklpr");
+    assertIndex(3, "abcdefjklpr");
+    assertIndex(4, "abcdstr");
+    assertIndex(5, "012345");
+    assertSize(6);
+    assertIndex(6, "abcdef");  // Validate inner nodes
+    assertIndex(7, "abcd");
+    assertIndex(8, "");
+    assertSize(9);
+    assertContent();
+  }
+
+  /**
+   * Writes lots of entries with labels "fooconcurrent[int]" at the same time.
+   * The set of labels written is deterministic, but the label:index mapping is
+   * not.
+   */
+  private void writeLotsOfEntriesConcurrently(final int numToWrite) throws InterruptedException {
+    final int NUM_THREADS = 10;
+    final CountDownLatch synchronizerLatch = new CountDownLatch(NUM_THREADS);
+
+    class IndexAdder extends TestThread {
+      @Override
+      public void runTest() throws Exception {
+        for (int i = 0; i < numToWrite; i++) {
+          synchronizerLatch.countDown();
+          synchronizerLatch.await();
+
+          String value = "fooconcurrent" + i;
+          mappings.put(psi.getOrCreateIndex(value), value);
+        }
+      }
+    }
+
+    Collection<TestThread> threads = new ArrayList<>();
+    for (int i = 0; i < NUM_THREADS; i++) {
+      TestThread thread = new IndexAdder();
+      thread.start();
+      threads.add(thread);
+    }
+
+    for (TestThread thread : threads) {
+      thread.joinAndAssertState(0);
+    }
+  }
+
+  @Test
+  public void testNormalOperation() throws Exception {
+    assertFalse(dataPath.exists());
+    assertFalse(journalPath.exists());
+    setupTestContent();
+    assertFalse(dataPath.exists());
+    assertFalse(journalPath.exists());
+
+    clock.advance(4);
+    assertIndex(9, "xyzqwerty"); // This should flush journal to disk.
+    assertFalse(dataPath.exists());
+    assertTrue(journalPath.exists());
+
+    psi.save(); // Successful save will remove journal file.
+    assertTrue(dataPath.exists());
+    assertFalse(journalPath.exists());
+
+    // Now restore data from file and verify it.
+    psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock);
+    assertFalse(journalPath.exists());
+    clock.advance(4);
+    assertSize(10);
+    assertContent();
+    assertFalse(journalPath.exists());
+  }
+
+  @Test
+  public void testJournalRecoveryWithoutMainDataFile() throws Exception {
+    assertFalse(dataPath.exists());
+    assertFalse(journalPath.exists());
+    setupTestContent();
+    assertFalse(dataPath.exists());
+    assertFalse(journalPath.exists());
+
+    clock.advance(4);
+    assertIndex(9, "abc1234"); // This should flush journal to disk.
+    assertFalse(dataPath.exists());
+    assertTrue(journalPath.exists());
+
+    // Now restore data from file and verify it. All data should be restored from journal;
+    psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock);
+    assertTrue(dataPath.exists());
+    assertFalse(journalPath.exists());
+    clock.advance(4);
+    assertSize(10);
+    assertContent();
+    assertFalse(journalPath.exists());
+  }
+
+  @Test
+  public void testJournalRecovery() throws Exception {
+    assertFalse(dataPath.exists());
+    assertFalse(journalPath.exists());
+    setupTestContent();
+    psi.save();
+    assertTrue(dataPath.exists());
+    assertFalse(journalPath.exists());
+    long oldDataFileLen = dataPath.getFileSize();
+
+    clock.advance(4);
+    assertIndex(9, "another record"); // This should flush journal to disk.
+    assertSize(10);
+    assertTrue(dataPath.exists());
+    assertTrue(journalPath.exists());
+
+    // Now restore data from file and verify it. All data should be restored from journal;
+    psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock);
+    assertTrue(dataPath.exists());
+    assertFalse(journalPath.exists());
+    assertTrue(dataPath.getFileSize() > oldDataFileLen); // data file should have been updated
+    clock.advance(4);
+    assertSize(10);
+    assertContent();
+    assertFalse(journalPath.exists());
+  }
+
+  @Test
+  public void testConcurrentWritesJournalRecovery() throws Exception {
+    assertFalse(dataPath.exists());
+    assertFalse(journalPath.exists());
+    setupTestContent();
+    psi.save();
+    assertTrue(dataPath.exists());
+    assertFalse(journalPath.exists());
+    long oldDataFileLen = dataPath.getFileSize();
+
+    int size = psi.size();
+    int numToWrite = 50000;
+    writeLotsOfEntriesConcurrently(numToWrite);
+    assertFalse(journalPath.exists());
+    clock.advance(4);
+    assertIndex(size + numToWrite, "another record"); // This should flush journal to disk.
+    assertSize(size + numToWrite + 1);
+    assertTrue(dataPath.exists());
+    assertTrue(journalPath.exists());
+
+    // Now restore data from file and verify it. All data should be restored from journal;
+    psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock);
+    assertTrue(dataPath.exists());
+    assertFalse(journalPath.exists());
+    assertTrue(dataPath.getFileSize() > oldDataFileLen); // data file should have been updated
+    clock.advance(4);
+    assertSize(size + numToWrite + 1);
+    assertContent();
+    assertFalse(journalPath.exists());
+  }
+
+  @Test
+  public void testCorruptedJournal() throws Exception {
+    FileSystemUtils.createDirectoryAndParents(journalPath.getParentDirectory());
+    FileSystemUtils.writeContentAsLatin1(journalPath, "bogus content");
+    try {
+      psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock);
+      fail();
+    } catch (IOException e) {
+      assertThat(e.getMessage()).contains("too short: Only 13 bytes");
+    }
+
+    journalPath.delete();
+    setupTestContent();
+    assertFalse(dataPath.exists());
+    assertFalse(journalPath.exists());
+
+    clock.advance(4);
+    assertIndex(9, "abc1234"); // This should flush journal to disk.
+    assertFalse(dataPath.exists());
+    assertTrue(journalPath.exists());
+
+    byte[] journalContent = FileSystemUtils.readContent(journalPath);
+
+    // Now restore data from file and verify it. All data should be restored from journal;
+    psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock);
+    assertTrue(dataPath.exists());
+    assertFalse(journalPath.exists());
+
+    // Now put back truncated journal. We should get an error.
+    assertTrue(dataPath.delete());
+    FileSystemUtils.writeContent(journalPath,
+        Arrays.copyOf(journalContent, journalContent.length - 1));
+    try {
+      psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock);
+      fail();
+    } catch (EOFException e) {
+      // Expected.
+    }
+
+    // Corrupt the journal with a negative size value.
+    byte[] journalCopy = Arrays.copyOf(journalContent, journalContent.length);
+    // Flip this bit to make the key size negative.
+    journalCopy[95] = -2;
+    FileSystemUtils.writeContent(journalPath,  journalCopy);
+    try {
+      psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock);
+      fail();
+    } catch (IOException e) {
+      // Expected.
+      assertThat(e.getMessage()).contains("corrupt key length");
+    }
+
+    // Now put back corrupted journal. We should get an error.
+    journalContent[journalContent.length - 13] = 100;
+    FileSystemUtils.writeContent(journalPath,  journalContent);
+    try {
+      psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock);
+      fail();
+    } catch (IOException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void testDupeIndexCorruption() throws Exception {
+    setupTestContent();
+    assertFalse(dataPath.exists());
+    assertFalse(journalPath.exists());
+
+    assertIndex(9, "abc1234"); // This should flush journal to disk.
+    psi.save();
+    assertTrue(dataPath.exists());
+    assertFalse(journalPath.exists());
+
+    byte[] content = FileSystemUtils.readContent(dataPath);
+
+    // We remove the data file, and instead create a corrupt journal.
+    //
+    // The journal has a header followed by a sequence of (String, int) pairs, where each int is a
+    // unique value. The String is encoded by the length (as an int), and the int is simply encoded
+    // as an int. Note that the DataOutputStream class uses big endian by default, so the low-order
+    // bits are at the end.
+    //
+    // For the purpose of this test, we want to make the journal contain two entries with the same
+    // index (which is illegal). The PersistentStringIndexer assigns int values in the usual order,
+    // starting with zero, and it now contains 9 entries. We simply change the last entry to an
+    // index that is guaranteed to already exist. If it is the index 1, we change it to 2, otherwise
+    // we change it to 1 - in both cases, the code currently guarantees that the duplicate comes
+    // earlier in the stream.
+    assertTrue(dataPath.delete());
+    content[content.length - 1] = content[content.length - 1] == 1 ? (byte) 2 : (byte) 1;
+    FileSystemUtils.writeContent(journalPath, content);
+
+    try {
+      psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock);
+      fail();
+    } catch (IOException e) {
+      // Expected.
+      assertThat(e.getMessage()).contains("Corrupted filename index has duplicate entry");
+    }
+  }
+
+  @Test
+  public void testDeferredIOFailure() throws Exception {
+    assertFalse(dataPath.exists());
+    assertFalse(journalPath.exists());
+    setupTestContent();
+    assertFalse(dataPath.exists());
+    assertFalse(journalPath.exists());
+
+    // Ensure that journal cannot be saved.
+    FileSystemUtils.createDirectoryAndParents(journalPath);
+
+    clock.advance(4);
+    assertIndex(9, "abc1234"); // This should flush journal to disk (and fail at that).
+    assertFalse(dataPath.exists());
+
+    // Subsequent updates should succeed even though journaling is disabled at this point.
+    clock.advance(4);
+    assertIndex(10, "another record");
+    try {
+      // Save should actually save main data file but then return us deferred IO failure
+      // from failed journal write.
+      psi.save();
+      fail();
+    } catch(IOException e) {
+      assertThat(e.getMessage()).contains(journalPath.getPathString() + " (Is a directory)");
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/util/ActionCacheTestHelper.java b/src/test/java/com/google/devtools/build/lib/actions/util/ActionCacheTestHelper.java
new file mode 100644
index 0000000..1db0249
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/util/ActionCacheTestHelper.java
@@ -0,0 +1,42 @@
+// Copyright 2015 Google Inc. 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.actions.util;
+
+import com.google.devtools.build.lib.actions.cache.ActionCache;
+
+import java.io.PrintStream;
+
+/**
+ * Utilities for tests that use the action cache.
+ */
+public class ActionCacheTestHelper {
+  private ActionCacheTestHelper() {}
+
+  /** A cache which does not remember anything.  Causes perpetual rebuilds! */
+  public static final ActionCache AMNESIAC_CACHE =
+    new ActionCache() {
+      @Override
+      public void put(String fingerprint, Entry entry) {}
+      @Override
+      public Entry get(String fingerprint) { return null; }
+      @Override
+      public void remove(String key) {}
+      @Override
+      public Entry createEntry(String key) { return new ActionCache.Entry(key); }
+      @Override
+      public long save() { return -1; }
+      @Override
+      public void dump(PrintStream out) { }
+    };
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/util/ActionsTestUtil.java b/src/test/java/com/google/devtools/build/lib/actions/util/ActionsTestUtil.java
new file mode 100644
index 0000000..2f601d4
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/util/ActionsTestUtil.java
@@ -0,0 +1,440 @@
+// Copyright 2015 Google Inc. 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.actions.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Predicate;
+import com.google.common.base.Predicates;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.devtools.build.lib.actions.AbstractAction;
+import com.google.devtools.build.lib.actions.AbstractActionOwner;
+import com.google.devtools.build.lib.actions.Action;
+import com.google.devtools.build.lib.actions.ActionExecutionContext;
+import com.google.devtools.build.lib.actions.ActionGraph;
+import com.google.devtools.build.lib.actions.ActionInputHelper;
+import com.google.devtools.build.lib.actions.ActionOwner;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.actions.ArtifactOwner;
+import com.google.devtools.build.lib.actions.Executor;
+import com.google.devtools.build.lib.actions.MutableActionGraph;
+import com.google.devtools.build.lib.actions.MutableActionGraph.ActionConflictException;
+import com.google.devtools.build.lib.actions.ResourceSet;
+import com.google.devtools.build.lib.actions.Root;
+import com.google.devtools.build.lib.actions.cache.MetadataHandler;
+import com.google.devtools.build.lib.exec.SingleBuildFileCache;
+import com.google.devtools.build.lib.syntax.Label;
+import com.google.devtools.build.lib.util.FileType;
+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.vfs.inmemoryfs.InMemoryFileSystem;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+
+import javax.annotation.Nullable;
+
+/**
+ * A bunch of utilities that are useful for test concerning actions, artifacts,
+ * etc.
+ */
+public final class ActionsTestUtil {
+
+  private final ActionGraph actionGraph;
+
+  public ActionsTestUtil(ActionGraph actionGraph) {
+    this.actionGraph = actionGraph;
+  }
+
+  private static final Label NULL_LABEL = Label.parseAbsoluteUnchecked("//null/action:owner");
+
+  public static ActionExecutionContext createContext(Executor executor, FileOutErr fileOutErr,
+      Path execRoot, MetadataHandler metadataHandler, @Nullable ActionGraph actionGraph) {
+    return new ActionExecutionContext(
+        executor,
+        new SingleBuildFileCache(execRoot.getPathString(), execRoot.getFileSystem()),
+        metadataHandler, fileOutErr,
+        actionGraph == null
+            ? null
+            : ActionInputHelper.actionGraphMiddlemanExpander(actionGraph));
+  }
+
+  /**
+   * A dummy ActionOwner implementation for use in tests.
+   */
+  public static class NullActionOwner extends AbstractActionOwner {
+    @Override
+    public Label getLabel() {
+      return NULL_LABEL;
+    }
+
+    @Override
+    public final String getConfigurationName() {
+      return "dummy-configuration";
+    }
+
+    @Override
+    public String getConfigurationMnemonic() {
+      return "dummy-configuration-mnemonic";
+    }
+
+    @Override
+    public final String getConfigurationShortCacheKey() {
+      return "dummy-configuration";
+    }
+  }
+
+  public static final Artifact DUMMY_ARTIFACT = new Artifact(
+      new PathFragment("dummy"),
+      Root.asSourceRoot(new InMemoryFileSystem().getRootDirectory()));
+
+  public static final ActionOwner NULL_ACTION_OWNER = new NullActionOwner();
+
+  public static final ArtifactOwner NULL_ARTIFACT_OWNER =
+      new ArtifactOwner() {
+        @Override
+        public Label getLabel() {
+          return NULL_LABEL;
+        }
+  };
+
+  public static class UncheckedActionConflictException extends RuntimeException {
+    public UncheckedActionConflictException(ActionConflictException e) {
+      super(e);
+    }
+  }
+
+  /**
+   * A dummy Action class for use in tests.
+   */
+  public static class NullAction extends AbstractAction {
+
+    public NullAction() {
+      super(NULL_ACTION_OWNER, Artifact.NO_ARTIFACTS, ImmutableList.of(DUMMY_ARTIFACT));
+    }
+
+    public NullAction(ActionOwner owner, Artifact... outputs) {
+      super(owner, Artifact.NO_ARTIFACTS, ImmutableList.copyOf(outputs));
+    }
+
+    public NullAction(Artifact... outputs) {
+      super(NULL_ACTION_OWNER, Artifact.NO_ARTIFACTS, ImmutableList.copyOf(outputs));
+    }
+
+    @Override
+    public String describeStrategy(Executor executor) {
+      return "";
+    }
+
+    @Override
+    public void execute(ActionExecutionContext actionExecutionContext) {
+    }
+
+    @Override protected String computeKey() { return "action"; }
+    @Override public ResourceSet estimateResourceConsumption(Executor executor) {
+      return ResourceSet.ZERO;
+    }
+    @Override
+    public String getMnemonic() {
+      return "Null";
+    }
+  }
+
+  /**
+   * For a bunch of actions, gets the basenames of the paths and accumulates
+   * them in a space separated string, like <code>foo.o bar.o baz.a</code>.
+   */
+  public static String baseNamesOf(Iterable<Artifact> artifacts) {
+    List<String> baseNames = baseArtifactNames(artifacts);
+    return Joiner.on(' ').join(baseNames);
+  }
+
+  /**
+   * For a bunch of actions, gets the basenames of the paths, sorts them in alphabetical
+   * order and accumulates them in a space separated string, for example
+   * <code>bar.o baz.a foo.o</code>.
+   */
+  public static String sortedBaseNamesOf(Iterable<Artifact> artifacts) {
+    List<String> baseNames = baseArtifactNames(artifacts);
+    Collections.sort(baseNames);
+    return Joiner.on(' ').join(baseNames);
+  }
+
+  /**
+   * For a bunch of artifacts, gets the basenames and accumulates them in a
+   * List.
+   */
+  public static List<String> baseArtifactNames(Iterable<Artifact> artifacts) {
+    List<String> baseNames = new ArrayList<>();
+    for (Artifact artifact : artifacts) {
+      baseNames.add(artifact.getExecPath().getBaseName());
+    }
+    return baseNames;
+  }
+
+  /**
+   * For a bunch of artifacts, gets the exec paths and accumulates them in a
+   * List.
+   */
+  public static List<String> execPaths(Iterable<Artifact> artifacts) {
+    List<String> names = new ArrayList<>();
+    for (Artifact artifact : artifacts) {
+      names.add(artifact.getExecPathString());
+    }
+    return names;
+  }
+
+  /**
+   * For a bunch of artifacts, gets the pretty printed names and accumulates them in a List. Note
+   * that this returns the root-relative paths, not the exec paths.
+   */
+  public static List<String> prettyArtifactNames(Iterable<Artifact> artifacts) {
+    List<String> result = new ArrayList<>();
+    for (Artifact artifact : artifacts) {
+      result.add(artifact.prettyPrint());
+    }
+    return result;
+  }
+
+  public static List<String> prettyJarNames(Iterable<Artifact> jars) {
+    List<String> result = new ArrayList<>();
+    for (Artifact jar : jars) {
+      result.add(jar.prettyPrint());
+    }
+    return result;
+  }
+
+  /**
+   * Returns the closure of the predecessors of any of the given types, joining the basenames of the
+   * artifacts into a space-separated string like "libfoo.a libbar.a libbaz.a".
+   */
+  public String predecessorClosureOf(Artifact artifact, FileType... types) {
+    return predecessorClosureOf(Collections.singleton(artifact), types);
+  }
+
+  /**
+   * Returns the closure of the predecessors of any of the given types.
+   */
+  public Collection<String> predecessorClosureAsCollection(Artifact artifact, FileType... types) {
+    return predecessorClosureAsCollection(Collections.singleton(artifact), types);
+  }
+
+  /**
+   * Returns the closure of the predecessors of any of the given types, joining the basenames of the
+   * artifacts into a space-separated string like "libfoo.a libbar.a libbaz.a".
+   */
+  public String predecessorClosureOf(Iterable<Artifact> artifacts, FileType... types) {
+    Set<Artifact> visited = artifactClosureOf(artifacts);
+    return baseNamesOf(FileType.filter(visited, types));
+  }
+
+  /**
+   * Returns the closure of the predecessors of any of the given types.
+   */
+  public Collection<String> predecessorClosureAsCollection(Iterable<Artifact> artifacts,
+      FileType... types) {
+    return baseArtifactNames(FileType.filter(artifactClosureOf(artifacts), types));
+  }
+
+  public String predecessorClosureOfJars(Iterable<Artifact> artifacts, FileType... types) {
+    return baseNamesOf(FileType.filter(artifactClosureOf(artifacts), types));
+  }
+
+  public Collection<String> predecessorClosureJarsAsCollection(Iterable<Artifact> artifacts,
+      FileType... types) {
+    Set<Artifact> visited = artifactClosureOf(artifacts);
+    return baseArtifactNames(FileType.filter(visited, types));
+  }
+
+  /**
+   * Returns the closure over the input files of an action.
+   */
+  public Set<Artifact> inputClosureOf(Action action) {
+    return artifactClosureOf(action.getInputs());
+  }
+
+  /**
+   * Returns the closure over the input files of an artifact.
+   */
+  public Set<Artifact> artifactClosureOf(Artifact artifact) {
+    return artifactClosureOf(Collections.singleton(artifact));
+  }
+
+  /**
+   * Returns the closure over the input files of an artifact, filtered by the given matcher.
+   */
+  public Set<Artifact> filteredArtifactClosureOf(Artifact artifact, Predicate<Artifact> matcher) {
+    return ImmutableSet.copyOf(Iterables.filter(artifactClosureOf(artifact), matcher));
+  }
+
+  /**
+   * Returns the closure over the input files of a set of artifacts.
+   */
+  public Set<Artifact> artifactClosureOf(Iterable<Artifact> artifacts) {
+    Set<Artifact> visited = new LinkedHashSet<>();
+    List<Artifact> toVisit = Lists.newArrayList(artifacts);
+    while (!toVisit.isEmpty()) {
+      Artifact current = toVisit.remove(0);
+      if (!visited.add(current)) {
+        continue;
+      }
+      Action generatingAction = actionGraph.getGeneratingAction(current);
+      if (generatingAction != null) {
+        Iterables.addAll(toVisit, generatingAction.getInputs());
+      }
+    }
+    return visited;
+  }
+
+  /**
+   * Returns the closure over the input files of a set of artifacts, filtered by the given matcher.
+   */
+  public Set<Artifact> filteredArtifactClosureOf(Iterable<Artifact> artifacts,
+      Predicate<Artifact> matcher) {
+    return ImmutableSet.copyOf(Iterables.filter(artifactClosureOf(artifacts), matcher));
+  }
+
+  /**
+   * Returns a predicate to match {@link Artifact}s with the given root-relative path suffix.
+   */
+  public static Predicate<Artifact> getArtifactSuffixMatcher(final String suffix) {
+    return new Predicate<Artifact>() {
+      @Override
+      public boolean apply(Artifact input) {
+        return input.getRootRelativePath().getPathString().endsWith(suffix);
+      }
+    };
+  }
+
+  /**
+   * Finds all the actions that are instances of <code>actionClass</code>
+   * in the transitive closure of prerequisites.
+   */
+  public <A extends Action> List<A> findTransitivePrerequisitesOf(Artifact artifact,
+      Class<A> actionClass, Predicate<Artifact> allowedArtifacts) {
+    List<A> actions = new ArrayList<>();
+    Set<Artifact> visited = new LinkedHashSet<>();
+    List<Artifact> toVisit = new LinkedList<>();
+    toVisit.add(artifact);
+    while (!toVisit.isEmpty()) {
+      Artifact current = toVisit.remove(0);
+      if (!visited.add(current)) {
+        continue;
+      }
+      Action generatingAction = actionGraph.getGeneratingAction(current);
+      if (generatingAction != null) {
+        Iterables.addAll(toVisit, Iterables.filter(generatingAction.getInputs(), allowedArtifacts));
+        if (actionClass.isInstance(generatingAction)) {
+          actions.add(actionClass.cast(generatingAction));
+        }
+      }
+    }
+    return actions;
+  }
+
+  public <A extends Action> List<A> findTransitivePrerequisitesOf(
+      Artifact artifact, Class<A> actionClass) {
+    return findTransitivePrerequisitesOf(artifact, actionClass, Predicates.<Artifact>alwaysTrue());
+  }
+
+  /**
+   * Looks in the given artifacts Iterable for the first Artifact whose path ends with the given
+   * suffix and returns its generating Action.
+   */
+  public Action getActionForArtifactEndingWith(Iterable<Artifact> artifacts, String suffix) {
+    Artifact a = getFirstArtifactEndingWith(artifacts, suffix);
+    return a != null ? actionGraph.getGeneratingAction(a) : null;
+  }
+
+  /**
+   * Looks in the given artifacts Iterable for the first Artifact whose path ends with the given
+   * suffix and returns the Artifact.
+   */
+  public static Artifact getFirstArtifactEndingWith(
+      Iterable<Artifact> artifacts, String suffix) {
+    for (Artifact a : artifacts) {
+      if (a.getExecPath().getPathString().endsWith(suffix)) {
+        return a;
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Returns the first artifact which is an input to "action" and has the
+   * specified basename. An assertion error is raised if none is found.
+   */
+  public static Artifact getInput(Action action, String basename) {
+    for (Artifact artifact : action.getInputs()) {
+      if (artifact.getExecPath().getBaseName().equals(basename)) {
+        return artifact;
+      }
+    }
+    throw new AssertionError("No input with basename '" + basename + "' in action " + action);
+  }
+
+  /**
+   * Returns true if an artifact that is an input to "action" with the specific
+   * basename exists.
+   */
+  public static boolean hasInput(Action action, String basename) {
+    try {
+      getInput(action, basename);
+      return true;
+    } catch (AssertionError e) {
+      return false;
+    }
+  }
+
+  /**
+   * Assert that an artifact is the primary output of its generating action.
+   */
+  public void assertPrimaryInputAndOutputArtifacts(Artifact input, Artifact output) {
+    Action generatingAction = actionGraph.getGeneratingAction(output);
+    assertThat(generatingAction).isNotNull();
+    assertThat(generatingAction.getPrimaryOutput()).isEqualTo(output);
+    assertThat(generatingAction.getPrimaryInput()).isEqualTo(input);
+  }
+
+  /**
+   * Returns the first artifact which is an output of "action" and has the
+   * specified basename. An assertion error is raised if none is found.
+   */
+  public static Artifact getOutput(Action action, String basename) {
+    for (Artifact artifact : action.getOutputs()) {
+      if (artifact.getExecPath().getBaseName().equals(basename)) {
+        return artifact;
+      }
+    }
+    throw new AssertionError("No output with basename '" + basename + "' in action " + action);
+  }
+
+  public static void registerActionWith(Action action, MutableActionGraph actionGraph) {
+    try {
+      actionGraph.registerAction(action);
+    } catch (ActionConflictException e) {
+      throw new UncheckedActionConflictException(e);
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/util/DummyExecutor.java b/src/test/java/com/google/devtools/build/lib/actions/util/DummyExecutor.java
new file mode 100644
index 0000000..409227d
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/util/DummyExecutor.java
@@ -0,0 +1,86 @@
+// Copyright 2015 Google Inc. 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.actions.util;
+
+import com.google.common.eventbus.EventBus;
+import com.google.devtools.build.lib.actions.Executor;
+import com.google.devtools.build.lib.actions.SpawnActionContext;
+import com.google.devtools.build.lib.events.EventHandler;
+import com.google.devtools.build.lib.util.Clock;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.common.options.OptionsClassProvider;
+
+/**
+ * A dummy implementation of Executor.
+ */
+public final class DummyExecutor implements Executor {
+  private final Path inputDir;
+
+  /**
+   * @param inputDir
+   */
+  public DummyExecutor(Path inputDir) {
+    this.inputDir = inputDir;
+  }
+
+  @Override
+  public Path getExecRoot() {
+    return inputDir;
+  }
+
+  @Override
+  public Clock getClock() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public EventBus getEventBus() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean getVerboseFailures() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public EventHandler getEventHandler() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public <T extends ActionContext> T getContext(Class<? extends T> type) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public SpawnActionContext getSpawnActionContext(String mnemonic) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public OptionsClassProvider getOptions() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean reportsSubcommands() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void reportSubcommand(String reason, String message) {
+    throw new UnsupportedOperationException();
+  }
+}
\ No newline at end of file
diff --git a/src/test/java/com/google/devtools/build/lib/actions/util/LabelArtifactOwner.java b/src/test/java/com/google/devtools/build/lib/actions/util/LabelArtifactOwner.java
new file mode 100644
index 0000000..8e200e73
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/util/LabelArtifactOwner.java
@@ -0,0 +1,49 @@
+// Copyright 2015 Google Inc. 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.actions.util;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.devtools.build.lib.actions.ArtifactOwner;
+import com.google.devtools.build.lib.syntax.Label;
+
+import java.util.Objects;
+
+/** ArtifactOwner wrapper for Labels, for use in tests. */
+@VisibleForTesting
+public class LabelArtifactOwner implements ArtifactOwner {
+  private final Label label;
+
+  @VisibleForTesting
+  public LabelArtifactOwner(Label label) {
+    this.label = label;
+  }
+
+  @Override
+  public Label getLabel() {
+    return label;
+  }
+
+  @Override
+  public int hashCode() {
+    return label == null ? super.hashCode() : label.hashCode();
+  }
+
+  @Override
+  public boolean equals(Object that) {
+    if (!(that instanceof LabelArtifactOwner)) {
+      return false;
+    }
+    return Objects.equals(this.label, ((LabelArtifactOwner) that).label);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/util/TestAction.java b/src/test/java/com/google/devtools/build/lib/actions/util/TestAction.java
new file mode 100644
index 0000000..0861384
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/actions/util/TestAction.java
@@ -0,0 +1,176 @@
+// Copyright 2015 Google Inc. 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.actions.util;
+
+import static com.google.devtools.build.lib.actions.util.ActionsTestUtil.NULL_ACTION_OWNER;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.devtools.build.lib.actions.AbstractAction;
+import com.google.devtools.build.lib.actions.ActionExecutionContext;
+import com.google.devtools.build.lib.actions.ActionExecutionException;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.actions.Executor;
+import com.google.devtools.build.lib.actions.ResourceSet;
+import com.google.devtools.build.lib.util.StringUtilities;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+
+/**
+ * A dummy action for testing.  Its execution runs the specified
+ * Runnable or Callable, which is defined by the test case,
+ * and touches all the output files.
+ */
+public class TestAction extends AbstractAction {
+
+  public static final Runnable NO_EFFECT = new Runnable() { @Override public void run() {} };
+
+  private static final ResourceSet RESOURCES =
+      new ResourceSet(/*memoryMb=*/1.0, /*cpu=*/0.1, /*io=*/0.0);
+
+  private final Callable<Void> effect;
+
+  /** Use this constructor if the effect can't throw exceptions. */
+  public TestAction(Runnable effect,
+             Collection<Artifact> inputs,
+             Collection<Artifact> outputs) {
+    super(NULL_ACTION_OWNER, inputs, outputs);
+    this.effect = Executors.callable(effect, null);
+  }
+
+  /**
+   * Use this constructor if the effect can throw exceptions.
+   * Any checked exception thrown will be repackaged as an
+   * ActionExecutionException.
+   */
+  public TestAction(Callable<Void> effect,
+             Collection<Artifact> inputs,
+             Collection<Artifact> outputs) {
+    super(NULL_ACTION_OWNER, inputs, outputs);
+    this.effect = effect;
+  }
+
+  @Override
+  public Collection<Artifact> getMandatoryInputs() {
+    List<Artifact> mandatoryInputs = new ArrayList<>();
+    for (Artifact input : getInputs()) {
+      if (!input.getExecPath().getBaseName().endsWith(".optional")) {
+        mandatoryInputs.add(input);
+      }
+    }
+    return mandatoryInputs;
+  }
+
+  @Override
+  public boolean discoversInputs() {
+    for (Artifact input : getInputs()) {
+      if (!input.getExecPath().getBaseName().endsWith(".optional")) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  @Override
+  public void discoverInputs(ActionExecutionContext actionExecutionContext) {
+    Preconditions.checkState(discoversInputs(), this);
+  }
+
+  @Override
+  public void execute(ActionExecutionContext actionExecutionContext)
+      throws ActionExecutionException {
+    for (Artifact artifact : getInputs()) {
+      // Do not check *.optional artifacts - artifacts with such extension are
+      // used by tests to specify artifacts that may or may not be missing.
+      // This is used, e.g., to test Blaze behavior when action has missing
+      // input artifacts but still is successfully executed.
+      if (!artifact.getPath().exists() &&
+          !artifact.getExecPath().getBaseName().endsWith(".optional")) {
+        throw new IllegalStateException("action's input file does not exist: "
+            + artifact.getPath());
+      }
+    }
+
+    try {
+      effect.call();
+    } catch (RuntimeException | Error e) {
+      throw e;
+    } catch (Exception e) {
+      throw new ActionExecutionException("TestAction failed due to exception",
+                                         e, this, false);
+    }
+
+    try {
+      for (Artifact artifact: getOutputs()) {
+        FileSystemUtils.touchFile(artifact.getPath());
+      }
+    } catch (IOException e) {
+      throw new AssertionError(e);
+    }
+  }
+
+  @Override
+  public String describeStrategy(Executor executor) {
+    return "";
+  }
+
+  @Override
+  protected String computeKey() {
+    List<String> outputsList = new ArrayList<>();
+    for (Artifact output : getOutputs()) {
+      outputsList.add(output.getPath().getPathString());
+    }
+    // This could use a functional iterable and avoid creating a list
+    return "test " + StringUtilities.combineKeys(outputsList);
+  }
+
+  @Override
+  public String getMnemonic() { return "Test"; }
+
+  @Override
+  public ResourceSet estimateResourceConsumption(Executor executor) {
+    return RESOURCES;
+  }
+
+
+  /** No-op action that has exactly one output, and can be a middleman action. */
+  public static class DummyAction extends TestAction {
+    private static final Runnable NOOP = new Runnable() {
+      @Override
+      public void run() {}
+    };
+
+    private final MiddlemanType type;
+
+    public DummyAction(Collection<Artifact> inputs, Artifact output, MiddlemanType type) {
+      super(NOOP, inputs, ImmutableList.of(output));
+      this.type = type;
+    }
+
+    public DummyAction(Collection<Artifact> inputs, Artifact output) {
+      this(inputs, output, MiddlemanType.NORMAL);
+    }
+
+    @Override
+    public MiddlemanType getActionType() {
+      return type;
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/collect/CollectionUtilsTest.java b/src/test/java/com/google/devtools/build/lib/collect/CollectionUtilsTest.java
new file mode 100644
index 0000000..2505501
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/collect/CollectionUtilsTest.java
@@ -0,0 +1,175 @@
+// Copyright 2014 Google Inc. 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.collect;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.devtools.build.lib.collect.nestedset.NestedSet;
+import com.google.devtools.build.lib.collect.nestedset.NestedSetBuilder;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Tests for {@link CollectionUtils}.
+ */
+
+@RunWith(JUnit4.class)
+public class CollectionUtilsTest {
+
+  @Test
+  public void testDuplicatedElementsOf() {
+    assertDups(ImmutableList.<Integer>of(), ImmutableSet.<Integer>of());
+    assertDups(ImmutableList.of(0), ImmutableSet.<Integer>of());
+    assertDups(ImmutableList.of(0, 0, 0), ImmutableSet.of(0));
+    assertDups(ImmutableList.of(1, 2, 3, 1, 2, 3), ImmutableSet.of(1, 2, 3));
+    assertDups(ImmutableList.of(1, 2, 3, 1, 2, 3, 4), ImmutableSet.of(1, 2, 3));
+    assertDups(ImmutableList.of(1, 2, 3, 4), ImmutableSet.<Integer>of());
+  }
+
+  private static void assertDups(List<Integer> collection, Set<Integer> dups) {
+    assertEquals(dups, CollectionUtils.duplicatedElementsOf(collection));
+  }
+
+  @Test
+  public void testIsImmutable() throws Exception {
+    assertTrue(CollectionUtils.isImmutable(ImmutableList.of(1, 2, 3)));
+    assertTrue(CollectionUtils.isImmutable(ImmutableSet.of(1, 2, 3)));
+
+    NestedSet<Integer> ns = NestedSetBuilder.<Integer>compileOrder()
+        .add(1).add(2).add(3).build();
+    assertTrue(CollectionUtils.isImmutable(ns));
+
+    NestedSet<Integer> ns2 = NestedSetBuilder.<Integer>linkOrder().add(1).add(2).add(3).build();
+    assertTrue(CollectionUtils.isImmutable(ns2));
+
+    IterablesChain<Integer> chain = IterablesChain.<Integer>builder().addElement(1).build();
+
+    assertTrue(CollectionUtils.isImmutable(chain));
+
+    assertFalse(CollectionUtils.isImmutable(Lists.newArrayList()));
+    assertFalse(CollectionUtils.isImmutable(Lists.newLinkedList()));
+    assertFalse(CollectionUtils.isImmutable(Sets.newHashSet()));
+    assertFalse(CollectionUtils.isImmutable(Sets.newLinkedHashSet()));
+
+    // The result of Iterables.concat() actually is immutable, but we have no way of checking if
+    // a given Iterable comes from concat().
+    assertFalse(CollectionUtils.isImmutable(Iterables.concat(ns, ns2)));
+
+    // We can override the check by using the ImmutableIterable wrapper.
+    assertTrue(CollectionUtils.isImmutable(
+        ImmutableIterable.from(Iterables.concat(ns, ns2))));
+  }
+
+  @Test
+  public void testCheckImmutable() throws Exception {
+    CollectionUtils.checkImmutable(ImmutableList.of(1, 2, 3));
+    CollectionUtils.checkImmutable(ImmutableSet.of(1, 2, 3));
+
+    try {
+      CollectionUtils.checkImmutable(Lists.newArrayList(1, 2, 3));
+    } catch (IllegalStateException e) {
+      return;
+    }
+    fail();
+  }
+
+  @Test
+  public void testMakeImmutable() throws Exception {
+    Iterable<Integer> immutableList = ImmutableList.of(1, 2, 3);
+    assertSame(immutableList, CollectionUtils.makeImmutable(immutableList));
+
+    Iterable<Integer> mutableList = Lists.newArrayList(1, 2, 3);
+    Iterable<Integer> converted = CollectionUtils.makeImmutable(mutableList);
+    assertNotSame(mutableList, converted);
+    assertEquals(mutableList, ImmutableList.copyOf(converted));
+  }
+
+  private static enum Small { ALPHA, BRAVO }
+  private static enum Large {
+    L0, L1, L2, L3, L4, L5, L6, L7, L8, L9,
+    L10, L11, L12, L13, L14, L15, L16, L17, L18, L19,
+    L20, L21, L22, L23, L24, L25, L26, L27, L28, L29,
+    L30, L31,
+  }
+
+  private static enum TooLarge {
+    T0, T1, T2, T3, T4, T5, T6, T7, T8, T9,
+    T10, T11, T12, T13, T14, T15, T16, T17, T18, T19,
+    T20, T21, T22, T23, T24, T25, T26, T27, T28, T29,
+    T30, T31, T32,
+  }
+
+  private static enum Medium {
+    ONE, TWO, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT,
+  }
+
+  private <T extends Enum<T>> void assertAllDifferent(Class<T> clazz) throws Exception {
+    Set<EnumSet<T>> allSets = new HashSet<>();
+
+    int maxBits = 1 << clazz.getEnumConstants().length;
+    for (int i = 0; i < maxBits; i++) {
+      EnumSet<T> set = CollectionUtils.fromBits(i, clazz);
+      int back = CollectionUtils.toBits(set);
+      assertEquals(back, i);  // Assert that a roundtrip is idempotent
+      allSets.add(set);
+    }
+
+    assertEquals(maxBits, allSets.size());  // Assert that every decoded value is different
+  }
+
+  @Test
+  public void testEnumBitfields() throws Exception {
+    assertEquals(0, CollectionUtils.<Small>toBits());
+    assertEquals(EnumSet.noneOf(Small.class), CollectionUtils.fromBits(0, Small.class));
+    assertEquals(3, CollectionUtils.toBits(Small.ALPHA, Small.BRAVO));
+    assertEquals(10, CollectionUtils.toBits(Medium.TWO, Medium.FOUR));
+    assertEquals(EnumSet.of(Medium.SEVEN, Medium.EIGHT),
+        CollectionUtils.fromBits(192, Medium.class));
+
+    assertAllDifferent(Small.class);
+    assertAllDifferent(Medium.class);
+    assertAllDifferent(Large.class);
+
+    try {
+      CollectionUtils.toBits(TooLarge.T32);
+      fail();
+    } catch (IllegalArgumentException e) {
+      // good
+    }
+
+    try {
+      CollectionUtils.fromBits(0, TooLarge.class);
+      fail();
+    } catch (IllegalArgumentException e) {
+      // good
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/collect/ImmutableSortedKeyListMultimapTest.java b/src/test/java/com/google/devtools/build/lib/collect/ImmutableSortedKeyListMultimapTest.java
new file mode 100644
index 0000000..1249f6d
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/collect/ImmutableSortedKeyListMultimapTest.java
@@ -0,0 +1,352 @@
+// Copyright 2014 Google Inc. 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.collect;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.HashMultiset;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.testing.google.UnmodifiableCollectionTests;
+import com.google.common.testing.EqualsTester;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.AbstractMap.SimpleImmutableEntry;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A test for {@link ImmutableSortedKeyListMultimap}. Started out as a copy of
+ * ImmutableListMultimapTest.
+ */
+@RunWith(JUnit4.class)
+public class ImmutableSortedKeyListMultimapTest {
+
+  @Test
+  public void builderPutAllIterable() {
+    ImmutableSortedKeyListMultimap.Builder<String, Integer> builder
+        = ImmutableSortedKeyListMultimap.builder();
+    builder.putAll("foo", Arrays.asList(1, 2, 3));
+    builder.putAll("bar", Arrays.asList(4, 5));
+    builder.putAll("foo", Arrays.asList(6, 7));
+    Multimap<String, Integer> multimap = builder.build();
+    assertEquals(Arrays.asList(1, 2, 3, 6, 7), multimap.get("foo"));
+    assertEquals(Arrays.asList(4, 5), multimap.get("bar"));
+    assertEquals(7, multimap.size());
+  }
+
+  @Test
+  public void builderPutAllVarargs() {
+    ImmutableSortedKeyListMultimap.Builder<String, Integer> builder
+        = ImmutableSortedKeyListMultimap.builder();
+    builder.putAll("foo", 1, 2, 3);
+    builder.putAll("bar", 4, 5);
+    builder.putAll("foo", 6, 7);
+    Multimap<String, Integer> multimap = builder.build();
+    assertEquals(Arrays.asList(1, 2, 3, 6, 7), multimap.get("foo"));
+    assertEquals(Arrays.asList(4, 5), multimap.get("bar"));
+    assertEquals(7, multimap.size());
+  }
+
+  @Test
+  public void builderPutAllMultimap() {
+    Multimap<String, Integer> toPut = LinkedListMultimap.create();
+    toPut.put("foo", 1);
+    toPut.put("bar", 4);
+    toPut.put("foo", 2);
+    toPut.put("foo", 3);
+    Multimap<String, Integer> moreToPut = LinkedListMultimap.create();
+    moreToPut.put("foo", 6);
+    moreToPut.put("bar", 5);
+    moreToPut.put("foo", 7);
+    ImmutableSortedKeyListMultimap.Builder<String, Integer> builder
+        = ImmutableSortedKeyListMultimap.builder();
+    builder.putAll(toPut);
+    builder.putAll(moreToPut);
+    Multimap<String, Integer> multimap = builder.build();
+    assertEquals(Arrays.asList(1, 2, 3, 6, 7), multimap.get("foo"));
+    assertEquals(Arrays.asList(4, 5), multimap.get("bar"));
+    assertEquals(7, multimap.size());
+  }
+
+  @Test
+  public void builderPutAllWithDuplicates() {
+    ImmutableSortedKeyListMultimap.Builder<String, Integer> builder
+        = ImmutableSortedKeyListMultimap.builder();
+    builder.putAll("foo", 1, 2, 3);
+    builder.putAll("bar", 4, 5);
+    builder.putAll("foo", 1, 6, 7);
+    ImmutableSortedKeyListMultimap<String, Integer> multimap = builder.build();
+    assertEquals(Arrays.asList(1, 2, 3, 1, 6, 7), multimap.get("foo"));
+    assertEquals(Arrays.asList(4, 5), multimap.get("bar"));
+    assertEquals(8, multimap.size());
+  }
+
+  @Test
+  public void builderPutWithDuplicates() {
+    ImmutableSortedKeyListMultimap.Builder<String, Integer> builder
+        = ImmutableSortedKeyListMultimap.builder();
+    builder.putAll("foo", 1, 2, 3);
+    builder.putAll("bar", 4, 5);
+    builder.put("foo", 1);
+    ImmutableSortedKeyListMultimap<String, Integer> multimap = builder.build();
+    assertEquals(Arrays.asList(1, 2, 3, 1), multimap.get("foo"));
+    assertEquals(Arrays.asList(4, 5), multimap.get("bar"));
+    assertEquals(6, multimap.size());
+  }
+
+  @Test
+  public void builderPutAllMultimapWithDuplicates() {
+    Multimap<String, Integer> toPut = LinkedListMultimap.create();
+    toPut.put("foo", 1);
+    toPut.put("bar", 4);
+    toPut.put("foo", 2);
+    toPut.put("foo", 1);
+    toPut.put("bar", 5);
+    Multimap<String, Integer> moreToPut = LinkedListMultimap.create();
+    moreToPut.put("foo", 6);
+    moreToPut.put("bar", 4);
+    moreToPut.put("foo", 7);
+    moreToPut.put("foo", 2);
+    ImmutableSortedKeyListMultimap.Builder<String, Integer> builder
+        = ImmutableSortedKeyListMultimap.builder();
+    builder.putAll(toPut);
+    builder.putAll(moreToPut);
+    Multimap<String, Integer> multimap = builder.build();
+    assertEquals(Arrays.asList(1, 2, 1, 6, 7, 2), multimap.get("foo"));
+    assertEquals(Arrays.asList(4, 5, 4), multimap.get("bar"));
+    assertEquals(9, multimap.size());
+  }
+
+  @Test
+  public void builderPutNullKey() {
+    Multimap<String, Integer> toPut = LinkedListMultimap.create();
+    toPut.put("foo", null);
+    ImmutableSortedKeyListMultimap.Builder<String, Integer> builder
+        = ImmutableSortedKeyListMultimap.builder();
+    try {
+      builder.put(null, 1);
+      fail();
+    } catch (NullPointerException expected) {}
+    try {
+      builder.putAll(null, Arrays.asList(1, 2, 3));
+      fail();
+    } catch (NullPointerException expected) {}
+    try {
+      builder.putAll(null, 1, 2, 3);
+      fail();
+    } catch (NullPointerException expected) {}
+    try {
+      builder.putAll(toPut);
+      fail();
+    } catch (NullPointerException expected) {}
+  }
+
+  @Test
+  public void builderPutNullValue() {
+    Multimap<String, Integer> toPut = LinkedListMultimap.create();
+    toPut.put(null, 1);
+    ImmutableSortedKeyListMultimap.Builder<String, Integer> builder
+        = ImmutableSortedKeyListMultimap.builder();
+    try {
+      builder.put("foo", null);
+      fail();
+    } catch (NullPointerException expected) {}
+    try {
+      builder.putAll("foo", Arrays.asList(1, null, 3));
+      fail();
+    } catch (NullPointerException expected) {}
+    try {
+      builder.putAll("foo", 1, null, 3);
+      fail();
+    } catch (NullPointerException expected) {}
+    try {
+      builder.putAll(toPut);
+      fail();
+    } catch (NullPointerException expected) {}
+  }
+
+  @Test
+  public void copyOf() {
+    ArrayListMultimap<String, Integer> input = ArrayListMultimap.create();
+    input.put("foo", 1);
+    input.put("bar", 2);
+    input.put("foo", 3);
+    Multimap<String, Integer> multimap = ImmutableSortedKeyListMultimap.copyOf(input);
+    assertEquals(multimap, input);
+    assertEquals(input, multimap);
+  }
+
+  @Test
+  public void copyOfWithDuplicates() {
+    ArrayListMultimap<String, Integer> input = ArrayListMultimap.create();
+    input.put("foo", 1);
+    input.put("bar", 2);
+    input.put("foo", 3);
+    input.put("foo", 1);
+    Multimap<String, Integer> multimap = ImmutableSortedKeyListMultimap.copyOf(input);
+    assertEquals(multimap, input);
+    assertEquals(input, multimap);
+  }
+
+  @Test
+  public void copyOfEmpty() {
+    ArrayListMultimap<String, Integer> input = ArrayListMultimap.create();
+    Multimap<String, Integer> multimap = ImmutableSortedKeyListMultimap.copyOf(input);
+    assertEquals(multimap, input);
+    assertEquals(input, multimap);
+  }
+
+  @Test
+  public void copyOfImmutableListMultimap() {
+    Multimap<String, Integer> multimap = createMultimap();
+    assertSame(multimap, ImmutableSortedKeyListMultimap.copyOf(multimap));
+  }
+
+  @Test
+  public void copyOfNullKey() {
+    ArrayListMultimap<String, Integer> input = ArrayListMultimap.create();
+    input.put(null, 1);
+    try {
+      ImmutableSortedKeyListMultimap.copyOf(input);
+      fail();
+    } catch (NullPointerException expected) {}
+  }
+
+  @Test
+  public void copyOfNullValue() {
+    ArrayListMultimap<String, Integer> input = ArrayListMultimap.create();
+    input.putAll("foo", Arrays.asList(1, null, 3));
+    try {
+      ImmutableSortedKeyListMultimap.copyOf(input);
+      fail();
+    } catch (NullPointerException expected) {}
+  }
+
+  @Test
+  public void emptyMultimapReads() {
+    Multimap<String, Integer> multimap = ImmutableSortedKeyListMultimap.of();
+    assertFalse(multimap.containsKey("foo"));
+    assertFalse(multimap.containsValue(1));
+    assertFalse(multimap.containsEntry("foo", 1));
+    assertTrue(multimap.entries().isEmpty());
+    assertTrue(multimap.equals(ArrayListMultimap.create()));
+    assertEquals(Collections.emptyList(), multimap.get("foo"));
+    assertEquals(0, multimap.hashCode());
+    assertTrue(multimap.isEmpty());
+    assertEquals(HashMultiset.create(), multimap.keys());
+    assertEquals(Collections.emptySet(), multimap.keySet());
+    assertEquals(0, multimap.size());
+    assertTrue(multimap.values().isEmpty());
+    assertEquals("{}", multimap.toString());
+  }
+
+  @Test
+  public void emptyMultimapWrites() {
+    Multimap<String, Integer> multimap = ImmutableSortedKeyListMultimap.of();
+    UnmodifiableCollectionTests.assertMultimapIsUnmodifiable(
+        multimap, "foo", 1);
+  }
+
+  private Multimap<String, Integer> createMultimap() {
+    return ImmutableSortedKeyListMultimap.<String, Integer>builder()
+        .put("foo", 1).put("bar", 2).put("foo", 3).build();
+  }
+
+  @Test
+  public void multimapReads() {
+    Multimap<String, Integer> multimap = createMultimap();
+    assertTrue(multimap.containsKey("foo"));
+    assertFalse(multimap.containsKey("cat"));
+    assertTrue(multimap.containsValue(1));
+    assertFalse(multimap.containsValue(5));
+    assertTrue(multimap.containsEntry("foo", 1));
+    assertFalse(multimap.containsEntry("cat", 1));
+    assertFalse(multimap.containsEntry("foo", 5));
+    assertFalse(multimap.entries().isEmpty());
+    assertEquals(3, multimap.size());
+    assertFalse(multimap.isEmpty());
+    assertEquals("{bar=[2], foo=[1, 3]}", multimap.toString());
+  }
+
+  @Test
+  public void multimapWrites() {
+    Multimap<String, Integer> multimap = createMultimap();
+    UnmodifiableCollectionTests.assertMultimapIsUnmodifiable(
+        multimap, "bar", 2);
+  }
+
+  @Test
+  public void multimapEquals() {
+    Multimap<String, Integer> multimap = createMultimap();
+    Multimap<String, Integer> arrayListMultimap
+        = ArrayListMultimap.create();
+    arrayListMultimap.putAll("foo", Arrays.asList(1, 3));
+    arrayListMultimap.put("bar", 2);
+
+    new EqualsTester()
+        .addEqualityGroup(multimap, createMultimap(), arrayListMultimap,
+            ImmutableSortedKeyListMultimap.<String, Integer>builder()
+                .put("bar", 2).put("foo", 1).put("foo", 3).build())
+        .addEqualityGroup(ImmutableSortedKeyListMultimap.<String, Integer>builder()
+            .put("bar", 2).put("foo", 3).put("foo", 1).build())
+        .addEqualityGroup(ImmutableSortedKeyListMultimap.<String, Integer>builder()
+            .put("foo", 2).put("foo", 3).put("foo", 1).build())
+        .addEqualityGroup(ImmutableSortedKeyListMultimap.<String, Integer>builder()
+            .put("bar", 2).put("foo", 3).build())
+        .testEquals();
+  }
+
+  @Test
+  public void asMap() {
+    ImmutableSortedKeyListMultimap.Builder<String, Integer> builder
+        = ImmutableSortedKeyListMultimap.builder();
+    builder.putAll("foo", Arrays.asList(1, 2, 3));
+    builder.putAll("bar", Arrays.asList(4, 5));
+    Map<String, Collection<Integer>> map = builder.build().asMap();
+    assertEquals(Arrays.asList(1, 2, 3), map.get("foo"));
+    assertEquals(Arrays.asList(4, 5), map.get("bar"));
+    assertEquals(2, map.size());
+    assertTrue(map.containsKey("foo"));
+    assertTrue(map.containsKey("bar"));
+    assertFalse(map.containsKey("notfoo"));
+  }
+
+  @Test
+  public void asMapEntries() {
+    ImmutableSortedKeyListMultimap.Builder<String, Integer> builder
+        = ImmutableSortedKeyListMultimap.builder();
+    builder.putAll("foo", Arrays.asList(1, 2, 3));
+    builder.putAll("bar", Arrays.asList(4, 5));
+    Set<Map.Entry<String, Collection<Integer>>> set = builder.build().asMap().entrySet();
+    Set<Map.Entry<String, Collection<Integer>>> other =
+        ImmutableSet.<Map.Entry<String, Collection<Integer>>>builder()
+        .add(new SimpleImmutableEntry<String, Collection<Integer>>("foo", Arrays.asList(1, 2, 3)))
+        .add(new SimpleImmutableEntry<String, Collection<Integer>>("bar", Arrays.asList(4, 5)))
+        .build();
+    assertEquals(other, set);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/collect/ImmutableSortedKeyMapTest.java b/src/test/java/com/google/devtools/build/lib/collect/ImmutableSortedKeyMapTest.java
new file mode 100644
index 0000000..c712695
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/collect/ImmutableSortedKeyMapTest.java
@@ -0,0 +1,288 @@
+// Copyright 2014 Google Inc. 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.collect;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.Maps;
+import com.google.common.testing.NullPointerTester;
+import com.google.devtools.build.lib.collect.ImmutableSortedKeyMap.Builder;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+
+/**
+ * A test for {@link ImmutableSortedKeyListMultimap}. Started out as a blatant copy of
+ * ImmutableListMapTest.
+ */
+@RunWith(JUnit4.class)
+public class ImmutableSortedKeyMapTest {
+
+  @Test
+  public void emptyBuilder() {
+    ImmutableSortedKeyMap<String, Integer> map
+        = ImmutableSortedKeyMap.<String, Integer>builder().build();
+    assertEquals(Collections.<String, Integer>emptyMap(), map);
+  }
+
+  @Test
+  public void singletonBuilder() {
+    ImmutableSortedKeyMap<String, Integer> map = ImmutableSortedKeyMap.<String, Integer>builder()
+        .put("one", 1)
+        .build();
+    assertMapEquals(map, "one", 1);
+  }
+
+  @Test
+  public void builder() {
+    ImmutableSortedKeyMap<String, Integer> map = ImmutableSortedKeyMap.<String, Integer>builder()
+        .put("one", 1)
+        .put("two", 2)
+        .put("three", 3)
+        .put("four", 4)
+        .put("five", 5)
+        .build();
+    assertMapEquals(map,
+        "five", 5, "four", 4, "one", 1, "three", 3, "two", 2);
+  }
+
+  @Test
+  public void builderPutAllWithEmptyMap() {
+    ImmutableSortedKeyMap<String, Integer> map = ImmutableSortedKeyMap.<String, Integer>builder()
+        .putAll(Collections.<String, Integer>emptyMap())
+        .build();
+    assertEquals(Collections.<String, Integer>emptyMap(), map);
+  }
+
+  @Test
+  public void builderPutAll() {
+    Map<String, Integer> toPut = new LinkedHashMap<>();
+    toPut.put("one", 1);
+    toPut.put("two", 2);
+    toPut.put("three", 3);
+    Map<String, Integer> moreToPut = new LinkedHashMap<>();
+    moreToPut.put("four", 4);
+    moreToPut.put("five", 5);
+
+    ImmutableSortedKeyMap<String, Integer> map = ImmutableSortedKeyMap.<String, Integer>builder()
+        .putAll(toPut)
+        .putAll(moreToPut)
+        .build();
+    assertMapEquals(map,
+        "five", 5, "four", 4, "one", 1, "three", 3, "two", 2);
+  }
+
+  @Test
+  public void builderReuse() {
+    ImmutableSortedKeyMap.Builder<String, Integer> builder =
+        ImmutableSortedKeyMap.<String, Integer>builder();
+    ImmutableSortedKeyMap<String, Integer> mapOne = builder
+        .put("one", 1)
+        .put("two", 2)
+        .build();
+    ImmutableSortedKeyMap<String, Integer> mapTwo = builder
+        .put("three", 3)
+        .put("four", 4)
+        .build();
+
+    assertMapEquals(mapOne, "one", 1, "two", 2);
+    assertMapEquals(mapTwo, "four", 4, "one", 1, "three", 3, "two", 2);
+  }
+
+  @Test
+  public void builderPutNullKey() {
+    Builder<String, Integer> builder = new Builder<>();
+    try {
+      builder.put(null, 1);
+      fail();
+    } catch (NullPointerException expected) {
+    }
+  }
+
+  @Test
+  public void builderPutNullValue() {
+    Builder<String, Integer> builder = new Builder<>();
+    try {
+      builder.put("one", null);
+      fail();
+    } catch (NullPointerException expected) {
+    }
+  }
+
+  @Test
+  public void builderPutNullKeyViaPutAll() {
+    Builder<String, Integer> builder = new Builder<>();
+    try {
+      builder.putAll(Collections.<String, Integer>singletonMap(null, 1));
+      fail();
+    } catch (NullPointerException expected) {
+    }
+  }
+
+  @Test
+  public void builderPutNullValueViaPutAll() {
+    Builder<String, Integer> builder = new Builder<>();
+    try {
+      builder.putAll(Collections.<String, Integer>singletonMap("one", null));
+      fail();
+    } catch (NullPointerException expected) {
+    }
+  }
+
+  @Test
+  public void of() {
+    assertMapEquals(
+        ImmutableSortedKeyMap.of("one", 1),
+        "one", 1);
+    assertMapEquals(
+        ImmutableSortedKeyMap.of("one", 1, "two", 2),
+        "one", 1, "two", 2);
+  }
+
+  @Test
+  public void ofNullKey() {
+    try {
+      ImmutableSortedKeyMap.of((String) null, 1);
+      fail();
+    } catch (NullPointerException expected) {
+    }
+
+    try {
+      ImmutableSortedKeyMap.of("one", 1, null, 2);
+      fail();
+    } catch (NullPointerException expected) {
+    }
+  }
+
+  @Test
+  public void ofNullValue() {
+    try {
+      ImmutableSortedKeyMap.of("one", null);
+      fail();
+    } catch (NullPointerException expected) {
+    }
+
+    try {
+      ImmutableSortedKeyMap.of("one", 1, "two", null);
+      fail();
+    } catch (NullPointerException expected) {
+    }
+  }
+
+  @Test
+  public void copyOfEmptyMap() {
+    ImmutableSortedKeyMap<String, Integer> copy
+        = ImmutableSortedKeyMap.copyOf(Collections.<String, Integer>emptyMap());
+    assertEquals(Collections.<String, Integer>emptyMap(), copy);
+    assertSame(copy, ImmutableSortedKeyMap.copyOf(copy));
+  }
+
+  @Test
+  public void copyOfSingletonMap() {
+    ImmutableSortedKeyMap<String, Integer> copy
+        = ImmutableSortedKeyMap.copyOf(Collections.singletonMap("one", 1));
+    assertMapEquals(copy, "one", 1);
+    assertSame(copy, ImmutableSortedKeyMap.copyOf(copy));
+  }
+
+  @Test
+  public void copyOf() {
+    Map<String, Integer> original = new LinkedHashMap<>();
+    original.put("one", 1);
+    original.put("two", 2);
+    original.put("three", 3);
+
+    ImmutableSortedKeyMap<String, Integer> copy = ImmutableSortedKeyMap.copyOf(original);
+    assertMapEquals(copy, "one", 1, "three", 3, "two", 2);
+    assertSame(copy, ImmutableSortedKeyMap.copyOf(copy));
+  }
+
+  @Test
+  public void nullGet() {
+    ImmutableSortedKeyMap<String, Integer> map = ImmutableSortedKeyMap.of("one", 1);
+    assertNull(map.get(null));
+  }
+
+  @Test
+  public void nullPointers() {
+    NullPointerTester tester = new NullPointerTester();
+    tester.testAllPublicStaticMethods(ImmutableSortedKeyMap.class);
+    tester.testAllPublicInstanceMethods(
+        new ImmutableSortedKeyMap.Builder<String, Object>());
+    tester.testAllPublicInstanceMethods(ImmutableSortedKeyMap.<String, Integer>of());
+    tester.testAllPublicInstanceMethods(ImmutableSortedKeyMap.of("one", 1));
+    tester.testAllPublicInstanceMethods(
+        ImmutableSortedKeyMap.of("one", 1, "two", 2));
+  }
+
+  private static <K, V> void assertMapEquals(Map<K, V> map,
+      Object... alternatingKeysAndValues) {
+    assertEquals(map.size(), alternatingKeysAndValues.length / 2);
+    int i = 0;
+    for (Entry<K, V> entry : map.entrySet()) {
+      assertEquals(alternatingKeysAndValues[i++], entry.getKey());
+      assertEquals(alternatingKeysAndValues[i++], entry.getValue());
+    }
+  }
+
+  private static class IntHolder implements Serializable {
+    public int value;
+
+    public IntHolder(int value) {
+      this.value = value;
+    }
+
+    @Override public boolean equals(Object o) {
+      return (o instanceof IntHolder) && ((IntHolder) o).value == value;
+    }
+
+    @Override public int hashCode() {
+      return value;
+    }
+
+    private static final long serialVersionUID = 5;
+  }
+
+  @Test
+  public void mutableValues() {
+    IntHolder holderA = new IntHolder(1);
+    IntHolder holderB = new IntHolder(2);
+    Map<String, IntHolder> map = ImmutableSortedKeyMap.of("a", holderA, "b", holderB);
+    holderA.value = 3;
+    assertTrue(map.entrySet().contains(
+        Maps.immutableEntry("a", new IntHolder(3))));
+    Map<String, Integer> intMap = ImmutableSortedKeyMap.of("a", 3, "b", 2);
+    assertEquals(intMap.hashCode(), map.entrySet().hashCode());
+    assertEquals(intMap.hashCode(), map.hashCode());
+  }
+
+  @Test
+  public void toStringTest() {
+    Map<String, Integer> map = ImmutableSortedKeyMap.of("a", 1, "b", 2);
+    assertEquals("{a=1, b=2}", map.toString());
+    map = ImmutableSortedKeyMap.of();
+    assertEquals("{}", map.toString());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/collect/IterablesChainTest.java b/src/test/java/com/google/devtools/build/lib/collect/IterablesChainTest.java
new file mode 100644
index 0000000..734d801
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/collect/IterablesChainTest.java
@@ -0,0 +1,60 @@
+// Copyright 2014 Google Inc. 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.collect;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Arrays;
+
+/**
+ * A test for {@link IterablesChain}.
+ */
+@RunWith(JUnit4.class)
+public class IterablesChainTest {
+
+  @Test
+  public void addElement() {
+    IterablesChain.Builder<String> builder = IterablesChain.builder();
+    builder.addElement("a");
+    builder.addElement("b");
+    assertEquals(Arrays.asList("a", "b"), ImmutableList.copyOf(builder.build()));
+  }
+
+  @Test
+  public void add() {
+    IterablesChain.Builder<String> builder = IterablesChain.builder();
+    builder.add(ImmutableList.of("a", "b"));
+    assertEquals(Arrays.asList("a", "b"), ImmutableList.copyOf(builder.build()));
+  }
+
+  @Test
+  public void isEmpty() {
+    IterablesChain.Builder<String> builder = IterablesChain.builder();
+    assertTrue(builder.isEmpty());
+    builder.addElement("a");
+    assertFalse(builder.isEmpty());
+    builder = IterablesChain.builder();
+    assertTrue(builder.isEmpty());
+    builder.add(ImmutableList.of("a"));
+    assertFalse(builder.isEmpty());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/collect/nestedset/CompileOrderExpanderTest.java b/src/test/java/com/google/devtools/build/lib/collect/nestedset/CompileOrderExpanderTest.java
new file mode 100644
index 0000000..926ce2d
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/collect/nestedset/CompileOrderExpanderTest.java
@@ -0,0 +1,64 @@
+// Copyright 2014 Google Inc. 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.collect.nestedset;
+
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.List;
+
+/**
+ * Tests for {@link CompileOrderExpander}.
+ */
+@RunWith(JUnit4.class)
+public class CompileOrderExpanderTest extends ExpanderTestBase {
+
+  @Override
+  protected Order expanderOrder() {
+    return Order.COMPILE_ORDER;
+  }
+
+  @Override
+  protected List<String> nestedResult() {
+    return ImmutableList.of("c", "a", "e", "b", "d");
+  }
+
+  @Override
+  protected List<String> nestedDuplicatesResult() {
+    return ImmutableList.of("c", "a", "e", "b", "d");
+  }
+
+  @Override
+  protected List<String> chainResult() {
+    return ImmutableList.of("c", "b", "a");
+  }
+
+  @Override
+  protected List<String> diamondResult() {
+    return ImmutableList.of("d", "b", "c", "a");
+  }
+
+  @Override
+  protected List<String> extendedDiamondResult() {
+    return ImmutableList.of("d", "e", "b", "c", "a");
+  }
+
+  @Override
+  protected List<String> extendedDiamondRightArmResult() {
+    return ImmutableList.of("d", "e", "b", "c2", "c", "a");
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/collect/nestedset/ExpanderTestBase.java b/src/test/java/com/google/devtools/build/lib/collect/nestedset/ExpanderTestBase.java
new file mode 100644
index 0000000..25448c6
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/collect/nestedset/ExpanderTestBase.java
@@ -0,0 +1,330 @@
+// Copyright 2014 Google Inc. 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.collect.nestedset;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+
+import junit.framework.TestCase;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Base class for tests of {@link NestedSetExpander} implementations.
+ *
+ * <p>This class provides test cases for representative nested set structures; the expected
+ * results must be provided by overriding the corresponding methods.
+ */
+public abstract class ExpanderTestBase extends TestCase  {
+
+  /**
+   * Returns the type of the expander under test.
+   */
+  protected abstract Order expanderOrder();
+
+  @Test
+  public void simple() {
+    NestedSet<String> s = prepareBuilder("c", "a", "b").build();
+
+    assertTrue(Arrays.equals(simpleResult().toArray(), s.directMembers()));
+    assertSetContents(simpleResult(), s);
+  }
+
+  @Test
+  public void simpleNoDuplicates() {
+    NestedSet<String> s = prepareBuilder("c", "a", "a", "a", "b").build();
+
+    assertTrue(Arrays.equals(simpleResult().toArray(), s.directMembers()));
+    assertSetContents(simpleResult(), s);
+  }
+
+  @Test
+  public void nesting() {
+    NestedSet<String> subset = prepareBuilder("c", "a", "e").build();
+    NestedSet<String> s = prepareBuilder("b", "d").addTransitive(subset).build();
+
+    assertSetContents(nestedResult(), s);
+  }
+
+  @Test
+  public void builderReuse() {
+    NestedSetBuilder<String> builder = prepareBuilder();
+    assertSetContents(Collections.<String>emptyList(), builder.build());
+
+    builder.add("b");
+    assertSetContents(ImmutableList.of("b"), builder.build());
+
+    builder.addAll(ImmutableList.of("d"));
+    Collection<String> expected = ImmutableList.copyOf(prepareBuilder("b", "d").build());
+    assertSetContents(expected, builder.build());
+
+    NestedSet<String> child = prepareBuilder("c", "a", "e").build();
+    builder.addTransitive(child);
+    assertSetContents(nestedResult(), builder.build());
+  }
+
+  @Test
+  public void builderChaining() {
+    NestedSet<String> s = prepareBuilder().add("b").addAll(ImmutableList.of("d"))
+        .addTransitive(prepareBuilder("c", "a", "e").build()).build();
+    assertSetContents(nestedResult(), s);
+  }
+
+  @Test
+  public void addAllOrdering() {
+    NestedSet<String> s1 = prepareBuilder().add("a").add("c").add("b").build();
+    NestedSet<String> s2 = prepareBuilder().addAll(ImmutableList.of("a", "c", "b")).build();
+
+    assertTrue(Arrays.equals(s1.directMembers(), s2.directMembers()));
+    assertCollectionsEqual(s1.toCollection(), s2.toCollection());
+    assertCollectionsEqual(s1.toList(), s2.toList());
+    assertCollectionsEqual(Lists.newArrayList(s1), Lists.newArrayList(s2));
+  }
+
+  @Test
+  public void mixedAddAllOrdering() {
+    NestedSet<String> s1 = prepareBuilder().add("a").add("b").add("c").add("d").build();
+    NestedSet<String> s2 = prepareBuilder().add("a").addAll(ImmutableList.of("b", "c")).add("d")
+        .build();
+
+    assertTrue(Arrays.equals(s1.directMembers(), s2.directMembers()));
+    assertCollectionsEqual(s1.toCollection(), s2.toCollection());
+    assertCollectionsEqual(s1.toList(), s2.toList());
+    assertCollectionsEqual(Lists.newArrayList(s1), Lists.newArrayList(s2));
+  }
+
+  @Test
+  public void transitiveDepsHandledSeparately() {
+    NestedSet<String> subset = prepareBuilder("c", "a", "e").build();
+    NestedSetBuilder<String> b = prepareBuilder();
+    // The fact that we add the transitive subset between the add("b") and add("d") calls should
+    // not change the result.
+    b.add("b");
+    b.addTransitive(subset);
+    b.add("d");
+    NestedSet<String> s = b.build();
+
+    assertSetContents(nestedResult(), s);
+  }
+
+  @Test
+  public void nestingNoDuplicates() {
+    NestedSet<String> subset = prepareBuilder("c", "a", "e").build();
+    NestedSet<String> s = prepareBuilder("b", "d", "e").addTransitive(subset).build();
+
+    assertSetContents(nestedDuplicatesResult(), s);
+  }
+
+  @Test
+  public void chain() {
+    NestedSet<String> c = prepareBuilder("c").build();
+    NestedSet<String> b = prepareBuilder("b").addTransitive(c).build();
+    NestedSet<String> a = prepareBuilder("a").addTransitive(b).build();
+
+    assertTrue(Arrays.equals(new String[]{"a"}, a.directMembers()));
+    assertSetContents(chainResult(), a);
+  }
+
+  @Test
+  public void diamond() {
+    NestedSet<String> d = prepareBuilder("d").build();
+    NestedSet<String> c = prepareBuilder("c").addTransitive(d).build();
+    NestedSet<String> b = prepareBuilder("b").addTransitive(d).build();
+    NestedSet<String> a = prepareBuilder("a").addTransitive(b).addTransitive(c).build();
+
+    assertTrue(Arrays.equals(new String[]{"a"}, a.directMembers()));
+    assertSetContents(diamondResult(), a);
+  }
+
+  @Test
+  public void extendedDiamond() {
+    NestedSet<String> d = prepareBuilder("d").build();
+    NestedSet<String> e = prepareBuilder("e").build();
+    NestedSet<String> b = prepareBuilder("b").addTransitive(d).addTransitive(e).build();
+    NestedSet<String> c = prepareBuilder("c").addTransitive(e).addTransitive(d).build();
+    NestedSet<String> a = prepareBuilder("a").addTransitive(b).addTransitive(c).build();
+    assertSetContents(extendedDiamondResult(), a);
+  }
+
+  @Test
+  public void extendedDiamondRightArm() {
+    NestedSet<String> d = prepareBuilder("d").build();
+    NestedSet<String> e = prepareBuilder("e").build();
+    NestedSet<String> b = prepareBuilder("b").addTransitive(d).addTransitive(e).build();
+    NestedSet<String> c2 = prepareBuilder("c2").addTransitive(e).addTransitive(d).build();
+    NestedSet<String> c = prepareBuilder("c").addTransitive(c2).build();
+    NestedSet<String> a = prepareBuilder("a").addTransitive(b).addTransitive(c).build();
+    assertSetContents(extendedDiamondRightArmResult(), a);
+  }
+
+  @Test
+  public void orderConflict() {
+    NestedSet<String> child1 = prepareBuilder("a", "b").build();
+    NestedSet<String> child2 = prepareBuilder("b", "a").build();
+    NestedSet<String> parent = prepareBuilder().addTransitive(child1).addTransitive(child2).build();
+    assertSetContents(orderConflictResult(), parent);
+  }
+
+  @Test
+  public void orderConflictNested() {
+    NestedSet<String> a = prepareBuilder("a").build();
+    NestedSet<String> b = prepareBuilder("b").build();
+    NestedSet<String> child1 = prepareBuilder().addTransitive(a).addTransitive(b).build();
+    NestedSet<String> child2 = prepareBuilder().addTransitive(b).addTransitive(a).build();
+    NestedSet<String> parent = prepareBuilder().addTransitive(child1).addTransitive(child2).build();
+    assertSetContents(orderConflictResult(), parent);
+  }
+
+  @Test
+  public void getOrderingEmpty() {
+    NestedSet<String> s = prepareBuilder().build();
+    assertTrue(s.isEmpty());
+    assertEquals(expanderOrder(), s.getOrder());
+  }
+
+  @Test
+  public void getOrdering() {
+    NestedSet<String> s = prepareBuilder("a", "b").build();
+    assertTrue(!s.isEmpty());
+    assertEquals(expanderOrder(), s.getOrder());
+  }
+
+  /**
+   * In case we have inner NestedSets with different order (allowed by the builder). We should
+   * maintain the order of the top-level NestedSet.
+   */
+  @Test
+  public void regressionOnOneTransitiveDep() {
+    NestedSet<String> subsub = NestedSetBuilder.<String>stableOrder().add("c").add("a").add("e")
+        .build();
+    NestedSet<String> sub = NestedSetBuilder.<String>stableOrder().add("b").add("d")
+        .addTransitive(subsub).build();
+    NestedSet<String> top = prepareBuilder().addTransitive(sub).build();
+    assertSetContents(nestedResult(), top);
+  }
+
+  @Test
+  public void nestingValidation() {
+    for (Order ordering : Order.values()) {
+      NestedSet<String> a = prepareBuilder("a", "b").build();
+      NestedSetBuilder<String> b = new NestedSetBuilder<>(ordering);
+      try {
+        b.addTransitive(a);
+        if (ordering != expanderOrder() && ordering != Order.STABLE_ORDER) {
+          fail();  // An exception was expected.
+        }
+      } catch (IllegalStateException e) {
+        if (ordering == expanderOrder() || ordering == Order.STABLE_ORDER) {
+          fail();  // No exception was expected.
+        }
+      }
+    }
+  }
+
+  private NestedSetBuilder<String> prepareBuilder(String... directMembers) {
+    NestedSetBuilder<String> builder = new NestedSetBuilder<>(expanderOrder());
+    builder.addAll(Lists.newArrayList(directMembers));
+    return builder;
+  }
+
+  protected final void assertSetContents(Collection<String> expected, NestedSet<String> set) {
+    assertEquals(expected, Lists.newArrayList(set));
+    assertEquals(expected, Lists.newArrayList(set.toCollection()));
+    assertEquals(expected, Lists.newArrayList(set.toList()));
+    assertEquals(expected, Lists.newArrayList(set.toSet()));
+  }
+
+  protected final void assertCollectionsEqual(
+      Collection<String> expected, Collection<String> actual) {
+    assertEquals(Lists.newArrayList(expected), Lists.newArrayList(actual));
+  }
+
+  /**
+   * Returns the enumeration of the nested set {"c", "a", "b"} in the
+   * implementation's enumeration order.
+   *
+   * @see #testSimple()
+   * @see #testSimpleNoDuplicates()
+   */
+  protected List<String> simpleResult() {
+    return ImmutableList.of("c", "a", "b");
+  }
+
+  /**
+   * Returns the enumeration of the nested set {"b", "d", {"c", "a", "e"}} in
+   * the implementation's enumeration order.
+   *
+   * @see #testNesting()
+   */
+  protected abstract List<String> nestedResult();
+
+  /**
+   * Returns the enumeration of the nested set {"b", "d", "e", {"c", "a", "e"}} in
+   * the implementation's enumeration order.
+   *
+   * @see #testNestingNoDuplicates()
+   */
+  protected abstract List<String> nestedDuplicatesResult();
+
+  /**
+   * Returns the enumeration of nested set {"a", {"b", {"c"}}} in the
+   * implementation's enumeration order.
+   *
+   * @see #testChain()
+   */
+  protected abstract List<String> chainResult();
+
+  /**
+   * Returns the enumeration of the nested set {"a", {"b", D}, {"c", D}}, where
+   * D is {"d"}, in the implementation's enumeration order.
+   *
+   * @see #testDiamond()
+   */
+  protected abstract List<String> diamondResult();
+
+  /**
+   * Returns the enumeration of the nested set {"a", {"b", E, D}, {"c", D, E}}, where
+   * D is {"d"} and E is {"e"}, in the implementation's enumeration order.
+   *
+   * @see #testExtendedDiamond()
+   */
+  protected abstract List<String> extendedDiamondResult();
+
+  /**
+   * Returns the enumeration of the nested set {"a", {"b", E, D}, {"c", C2}}, where
+   * D is {"d"}, E is {"e"} and C2 is {"c2", D, E}, in the implementation's enumeration order.
+   *
+   * @see #testExtendedDiamondRightArm()
+   */
+  protected abstract List<String> extendedDiamondRightArmResult();
+
+  /**
+   * Returns the enumeration of the nested set {{"a", "b"}, {"b", "a"}}.
+   *
+   * @see #testOrderConflict()
+   * @see #testOrderConflictNested()
+   */
+  protected List<String> orderConflictResult() {
+    return ImmutableList.of("a", "b");
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/collect/nestedset/LinkOrderExpanderTest.java b/src/test/java/com/google/devtools/build/lib/collect/nestedset/LinkOrderExpanderTest.java
new file mode 100644
index 0000000..5d69882
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/collect/nestedset/LinkOrderExpanderTest.java
@@ -0,0 +1,69 @@
+// Copyright 2014 Google Inc. 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.collect.nestedset;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.List;
+
+/**
+ * Tests for {@link LinkOrderExpander}.
+ */
+@RunWith(JUnit4.class)
+public class LinkOrderExpanderTest extends ExpanderTestBase {
+
+  @Override
+  protected Order expanderOrder() {
+    return Order.LINK_ORDER;
+  }
+
+  @Override
+  protected List<String> nestedResult() {
+    return ImmutableList.of("b", "d", "c", "a", "e");
+  }
+
+  @Override
+  protected List<String> nestedDuplicatesResult() {
+    return ImmutableList.of("b", "d", "c", "a", "e");
+  }
+
+  @Override
+  protected List<String> chainResult() {
+    return ImmutableList.of("a", "b", "c");
+  }
+
+  @Override
+  protected List<String> diamondResult() {
+    return ImmutableList.of("a", "b", "c", "d");
+  }
+
+  @Override
+  protected List<String> orderConflictResult() {
+    // Rightmost branch determines the order.
+    return ImmutableList.of("b", "a");
+  }
+
+  @Override
+  protected List<String> extendedDiamondResult() {
+    return ImmutableList.of("a", "b", "c", "e", "d");
+  }
+
+  @Override
+  protected List<String> extendedDiamondRightArmResult() {
+    return ImmutableList.of("a", "b", "c", "c2", "e", "d");
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/collect/nestedset/NaiveLinkOrderExpanderTest.java b/src/test/java/com/google/devtools/build/lib/collect/nestedset/NaiveLinkOrderExpanderTest.java
new file mode 100644
index 0000000..80faf7a
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/collect/nestedset/NaiveLinkOrderExpanderTest.java
@@ -0,0 +1,70 @@
+// Copyright 2014 Google Inc. 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.collect.nestedset;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.List;
+
+/**
+ * Tests for {@link NaiveLinkOrderExpander}.
+ */
+@RunWith(JUnit4.class)
+public class NaiveLinkOrderExpanderTest extends ExpanderTestBase {
+
+  @Override
+  protected Order expanderOrder() {
+    return Order.NAIVE_LINK_ORDER;
+  }
+
+  @Override
+  protected List<String> nestedResult() {
+    return ImmutableList.of("b", "d", "c", "a", "e");
+  }
+
+  @Override
+  protected List<String> nestedDuplicatesResult() {
+    return ImmutableList.of("b", "d", "e", "c", "a");
+  }
+
+  @Override
+  protected List<String> chainResult() {
+    return ImmutableList.of("a", "b", "c");
+  }
+
+  @Override
+  protected List<String> diamondResult() {
+    // This case illustrates why this implementation is called "naive".
+    return ImmutableList.of("a", "b", "d", "c");
+  }
+
+  @Override
+  protected List<String> orderConflictResult() {
+    // Leftmost branch determines the order.
+    return ImmutableList.of("a", "b");
+  }
+
+  @Override
+  protected List<String> extendedDiamondResult() {
+    return ImmutableList.of("a", "b", "d", "e", "c");
+  }
+
+  @Override
+  protected List<String> extendedDiamondRightArmResult() {
+    return ImmutableList.of("a", "b", "d", "e", "c", "c2");
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/collect/nestedset/NestedSetImplTest.java b/src/test/java/com/google/devtools/build/lib/collect/nestedset/NestedSetImplTest.java
new file mode 100644
index 0000000..e5cfae3
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/collect/nestedset/NestedSetImplTest.java
@@ -0,0 +1,245 @@
+// Copyright 2014 Google Inc. 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.collect.nestedset;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.testing.EqualsTester;
+
+import junit.framework.TestCase;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Arrays;
+
+/**
+ * Tests for {@link com.google.devtools.build.lib.collect.nestedset.NestedSet}.
+ */
+@RunWith(JUnit4.class)
+public class NestedSetImplTest extends TestCase {
+  @SafeVarargs
+  private static NestedSetBuilder<String> nestedSetBuilder(String... directMembers) {
+    NestedSetBuilder<String> builder = NestedSetBuilder.stableOrder();
+    builder.addAll(Lists.newArrayList(directMembers));
+    return builder;
+  }
+
+  @Test
+  public void simple() {
+    NestedSet<String> set = nestedSetBuilder("a").build();
+
+    assertTrue(Arrays.equals(new String[]{"a"}, set.directMembers()));
+    assertEquals(0, set.transitiveSets().length);
+    assertEquals(false, set.isEmpty());
+  }
+
+  @Test
+  public void flatToString() {
+    assertEquals("{}", nestedSetBuilder().build().toString());
+    assertEquals("{a}", nestedSetBuilder("a").build().toString());
+    assertEquals("{a, b}", nestedSetBuilder("a", "b").build().toString());
+  }
+
+  @Test
+  public void nestedToString() {
+    NestedSet<String> b = nestedSetBuilder("b").build();
+    NestedSet<String> c = nestedSetBuilder("c").build();
+
+    assertEquals("{a, {b}}",
+      nestedSetBuilder("a").addTransitive(b).build().toString());
+    assertEquals("{a, {b}, {c}}",
+      nestedSetBuilder("a").addTransitive(b).addTransitive(c).build().toString());
+
+    assertEquals("{b}", nestedSetBuilder().addTransitive(b).build().toString());
+  }
+
+  @Test
+  public void isEmpty() {
+    NestedSet<String> triviallyEmpty = nestedSetBuilder().build();
+    assertTrue(triviallyEmpty.isEmpty());
+
+    NestedSet<String> emptyLevel1 = nestedSetBuilder().addTransitive(triviallyEmpty).build();
+    assertTrue(emptyLevel1.isEmpty());
+
+    NestedSet<String> emptyLevel2 = nestedSetBuilder().addTransitive(emptyLevel1).build();
+    assertTrue(emptyLevel2.isEmpty());
+
+    NestedSet<String> triviallyNonEmpty = nestedSetBuilder("mango").build();
+    assertFalse(triviallyNonEmpty.isEmpty());
+
+    NestedSet<String> nonEmptyLevel1 = nestedSetBuilder().addTransitive(triviallyNonEmpty).build();
+    assertFalse(nonEmptyLevel1.isEmpty());
+
+    NestedSet<String> nonEmptyLevel2 = nestedSetBuilder().addTransitive(nonEmptyLevel1).build();
+    assertFalse(nonEmptyLevel2.isEmpty());
+  }
+
+  @Test
+  public void canIncludeAnyOrderInStableOrderAndViceVersa() {
+    NestedSetBuilder.stableOrder()
+        .addTransitive(NestedSetBuilder.compileOrder()
+            .addTransitive(NestedSetBuilder.stableOrder().build()).build())
+        .addTransitive(NestedSetBuilder.linkOrder()
+            .addTransitive(NestedSetBuilder.stableOrder().build()).build())
+        .addTransitive(NestedSetBuilder.naiveLinkOrder()
+            .addTransitive(NestedSetBuilder.stableOrder().build()).build()).build();
+    try {
+      NestedSetBuilder.compileOrder().addTransitive(NestedSetBuilder.linkOrder().build()).build();
+      fail("Shouldn't be able to include a non-stable order inside a different non-stable order!");
+    } catch (IllegalStateException e) {
+      // Expected.
+    }
+  }
+
+  /**
+   * A handy wrapper that allows us to use EqualsTester to test shallowEquals and shallowHashCode.
+   */
+  private static class SetWrapper<E> {
+    NestedSet<E> set;
+
+    SetWrapper(NestedSet<E> wrapped) {
+      set = wrapped;
+    }
+
+    @Override
+    public int hashCode() {
+      return set.shallowHashCode();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof SetWrapper)) {
+        return false;
+      }
+      try {
+        @SuppressWarnings("unchecked")
+        SetWrapper<E> other = (SetWrapper<E>) o;
+        return set.shallowEquals(other.set);
+      } catch (ClassCastException e) {
+        return false;
+      }
+    }
+  }
+
+  @SafeVarargs
+  private static <E> SetWrapper<E> flat(E... directMembers) {
+    NestedSetBuilder<E> builder = NestedSetBuilder.stableOrder();
+    builder.addAll(Lists.newArrayList(directMembers));
+    return new SetWrapper<E>(builder.build());
+  }
+
+  // Same as flat(), but allows duplicate elements.
+  @SafeVarargs
+  private static <E> SetWrapper<E> flatWithDuplicates(E... directMembers) {
+    return new SetWrapper<E>(
+        NestedSetBuilder.wrap(Order.STABLE_ORDER, ImmutableList.copyOf(directMembers)));
+  }
+
+  @SafeVarargs
+  private static <E> SetWrapper<E> nest(SetWrapper<E>... nested) {
+    NestedSetBuilder<E> builder = NestedSetBuilder.stableOrder();
+    for (SetWrapper<E> wrap : nested) {
+      builder.addTransitive(wrap.set);
+    }
+    return new SetWrapper<E>(builder.build());
+  }
+
+  @SafeVarargs
+  // Restricted to <Integer> to avoid ambiguity with the other nest() function.
+  private static SetWrapper<Integer> nest(Integer elem, SetWrapper<Integer>... nested) {
+    NestedSetBuilder<Integer> builder = NestedSetBuilder.stableOrder();
+    builder.add(elem);
+    for (SetWrapper<Integer> wrap : nested) {
+      builder.addTransitive(wrap.set);
+    }
+    return new SetWrapper<Integer>(builder.build());
+  }
+
+  @Test
+  public void shallowEquality() {
+    // Used below to check that inner nested sets can be compared by reference equality.
+    SetWrapper<Integer> myRef = nest(nest(flat(7, 8)), flat(9));
+
+    // Each "equality group" contains elements that are equal to one another
+    // (according to equals() and hashCode()), yet distinct from all elements
+    // of all other equality groups.
+    new EqualsTester()
+      .addEqualityGroup(flat(),
+                        flat(),
+                        nest(flat()))  // Empty set elision.
+      .addEqualityGroup(NestedSetBuilder.<Integer>linkOrder().build())
+      .addEqualityGroup(flat(3),
+                        flat(3),
+                        flat(3, 3))  // Element de-duplication.
+      .addEqualityGroup(flatWithDuplicates(3, 3))
+      .addEqualityGroup(flat(4),
+                        nest(flat(4))) // Automatic elision of one-element nested sets.
+      .addEqualityGroup(NestedSetBuilder.<Integer>linkOrder().add(4).build())
+      .addEqualityGroup(nestedSetBuilder("4").build())  // Like flat("4").
+      .addEqualityGroup(flat(3, 4),
+                        flat(3, 4))
+      // Shallow equality means that {{3},{5}} != {{3},{5}}.
+      .addEqualityGroup(nest(flat(3), flat(5)))
+      .addEqualityGroup(nest(flat(3), flat(5)))
+      .addEqualityGroup(nest(myRef),
+                        nest(myRef),
+                        nest(myRef, myRef))  // Set de-duplication.
+      .addEqualityGroup(nest(3, myRef))
+      .addEqualityGroup(nest(4, myRef))
+      .testEquals();
+
+    // Some things that are not tested by the above:
+    //  - ordering among direct members
+    //  - ordering among transitive sets
+  }
+
+  /** Checks that the builder always return a nested set with the correct order. */
+  @Test
+  public void correctOrder() {
+    for (Order order : Order.values()) {
+      for (int numDirects = 0; numDirects < 3; numDirects++) {
+        for (int numTransitives = 0; numTransitives < 3; numTransitives++) {
+          assertEquals(order, createNestedSet(order, numDirects, numTransitives, order).getOrder());
+          // We allow mixing orders if one of them is stable. This tests that the top level order is
+          // the correct one.
+          assertEquals(order,
+              createNestedSet(order, numDirects, numTransitives, Order.STABLE_ORDER).getOrder());
+        }
+      }
+    }
+  }
+
+  private NestedSet<Integer> createNestedSet(Order order, int numDirects, int numTransitives,
+      Order transitiveOrder) {
+    NestedSetBuilder<Integer> builder = new NestedSetBuilder<>(order);
+
+    for (int direct = 0; direct < numDirects; direct++) {
+      builder.add(direct);
+    }
+    for (int transitive = 0; transitive < numTransitives; transitive++) {
+      builder.addTransitive(new NestedSetBuilder<Integer>(transitiveOrder).add(transitive).build());
+    }
+    return builder.build();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/collect/nestedset/RecordingUniqueifierTest.java b/src/test/java/com/google/devtools/build/lib/collect/nestedset/RecordingUniqueifierTest.java
new file mode 100644
index 0000000..9764f4d
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/collect/nestedset/RecordingUniqueifierTest.java
@@ -0,0 +1,134 @@
+// Copyright 2014 Google Inc. 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.collect.nestedset;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+
+import junit.framework.TestCase;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Random;
+
+/**
+ * Tests for {@link RecordingUniqueifier}.
+ */
+@RunWith(JUnit4.class)
+public class RecordingUniqueifierTest extends TestCase {
+
+  private static final Random RANDOM = new Random();
+  
+  private static final int VERY_SMALL = 3; // one byte
+  private static final int SMALL = 11;     // two bytes
+  private static final int MEDIUM = 18;    // three bytes -- unmemoed
+  // For this one, the "* 8" is a bytes to bits (1 memo is 1 bit)
+  private static final int LARGE = (RecordingUniqueifier.LENGTH_THRESHOLD * 8) + 3;
+
+  private static final int[] SIZES = new int[] {VERY_SMALL, SMALL, MEDIUM, LARGE};
+  
+  private void doTest(int uniqueInputs, int deterministicHeadSize) throws Exception {
+    Preconditions.checkArgument(deterministicHeadSize <= uniqueInputs,
+        "deterministicHeadSize must be smaller than uniqueInputs");
+
+      // Setup
+
+      List<Integer> inputList = new ArrayList<>(uniqueInputs);
+      Collection<Integer> inputsDeduped = new LinkedHashSet<>(uniqueInputs);
+
+      for (int i = 0; i < deterministicHeadSize; i++) { // deterministic head
+        inputList.add(i);
+        inputsDeduped.add(i);
+      }
+
+      while (inputsDeduped.size() < uniqueInputs) { // random selectees
+        Integer i = RANDOM.nextInt(uniqueInputs);
+        inputList.add(i);
+        inputsDeduped.add(i);
+      }
+
+      // Unmemoed run
+
+      List<Integer> firstList = new ArrayList<>(uniqueInputs);
+      RecordingUniqueifier recordingUniqueifier = new RecordingUniqueifier();
+      for (Integer i : inputList) {
+        if (recordingUniqueifier.isUnique(i)) {
+          firstList.add(i);
+        }
+      }
+
+      // Potentially memo'ed run
+
+      List<Integer> secondList = new ArrayList<>(uniqueInputs);
+      Object memo = recordingUniqueifier.getMemo();
+      Uniqueifier uniqueifier = RecordingUniqueifier.createReplayUniqueifier(memo);
+      for (Integer i : inputList) {
+        if (uniqueifier.isUnique(i)) {
+          secondList.add(i);
+        }
+      }
+
+      // Evaluate results
+
+      inputsDeduped = ImmutableList.copyOf(inputsDeduped);
+      assertEquals("Unmemo'ed run has unexpected contents", inputsDeduped, firstList);
+      assertEquals("Memo'ed run has unexpected contents", inputsDeduped, secondList);
+  }
+
+  private void doTestWithLucidException(int uniqueInputs, int deterministicHeadSize)
+      throws Exception {
+    try {
+      doTest(uniqueInputs, deterministicHeadSize);
+    } catch (Exception e) {
+      throw new Exception("Failure in size: " + uniqueInputs, e);
+    }
+  }
+
+  @Test
+  public void noInputs() throws Exception {
+    doTestWithLucidException(0, 0);
+  }
+  
+  @Test
+  public void allUnique() throws Exception {
+    for (int size : SIZES) {
+      doTestWithLucidException(size, size);
+    }
+  }
+
+  @Test
+  public void fuzzedWithDeterministic2() throws Exception {
+    // The way that it is used, we know that the first two additions are not equal.
+    // Optimizations were made for this case in small memos.
+    for (int size : SIZES) {
+      doTestWithLucidException(size, 2);
+    }
+  }
+
+  @Test
+  public void fuzzedWithDeterministic2_otherSizes() throws Exception {
+    for (int i = 0; i < 100; i++) {
+      int size = RANDOM.nextInt(10000) + 2;
+      doTestWithLucidException(size, 2);
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/concurrent/AbstractQueueVisitorTest.java b/src/test/java/com/google/devtools/build/lib/concurrent/AbstractQueueVisitorTest.java
new file mode 100644
index 0000000..8a6485c
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/concurrent/AbstractQueueVisitorTest.java
@@ -0,0 +1,493 @@
+// Copyright 2014 Google Inc. 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.concurrent;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.common.util.concurrent.Uninterruptibles;
+import com.google.devtools.build.lib.testutil.TestThread;
+import com.google.devtools.build.lib.testutil.TestUtils;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Tests for AbstractQueueVisitor.
+ */
+@RunWith(JUnit4.class)
+public class AbstractQueueVisitorTest {
+
+  private static final RuntimeException THROWABLE = new RuntimeException();
+
+  @Test
+  public void simpleCounter() throws Exception {
+    CountingQueueVisitor counter = new CountingQueueVisitor();
+    counter.enqueue();
+    counter.work(false);
+    assertSame(10, counter.getCount());
+  }
+
+  @Test
+  public void callerOwnedPool() throws Exception {
+    ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 5, 0, TimeUnit.SECONDS,
+                                                         new LinkedBlockingQueue<Runnable>());
+    assertSame(0, executor.getActiveCount());
+
+    CountingQueueVisitor counter = new CountingQueueVisitor(executor);
+    counter.enqueue();
+    counter.work(false);
+    assertSame(10, counter.getCount());
+
+    executor.shutdown();
+    assertTrue(executor.awaitTermination(TestUtils.WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+  }
+
+  @Test
+  public void doubleCounter() throws Exception {
+    CountingQueueVisitor counter = new CountingQueueVisitor();
+    counter.enqueue();
+    counter.enqueue();
+    counter.work(false);
+    assertSame(10, counter.getCount());
+  }
+
+  @Test
+  public void exceptionFromWorkerThread() {
+    final RuntimeException myException = new IllegalStateException();
+    ConcreteQueueVisitor visitor = new ConcreteQueueVisitor();
+    visitor.enqueue(new Runnable() {
+      @Override
+      public void run() {
+        throw myException;
+      }
+    });
+
+    try {
+      // The exception from the worker thread should be
+      // re-thrown from the main thread.
+      visitor.work(false);
+      fail();
+    } catch (Exception e) {
+      assertSame(myException, e);
+    }
+  }
+
+  // Regression test for "AbstractQueueVisitor loses track of jobs if thread allocation fails".
+  @Test
+  public void threadPoolThrowsSometimes() throws Exception {
+    // In certain cases (for example, if the address space is almost entirely consumed by a huge
+    // JVM heap), thread allocation can fail with an OutOfMemoryError. If the queue visitor
+    // does not handle this gracefully, we lose track of tasks and hang the visitor indefinitely.
+
+    ThreadPoolExecutor executor = new ThreadPoolExecutor(3, 3, 0, TimeUnit.SECONDS,
+        new LinkedBlockingQueue<Runnable>()) {
+      private final AtomicLong count = new AtomicLong();
+
+      @Override
+      public void execute(Runnable command) {
+        long count = this.count.incrementAndGet();
+        if (count == 6) {
+          throw new Error("Could not create thread (fakeout)");
+        }
+        super.execute(command);
+      }
+    };
+
+    CountingQueueVisitor counter = new CountingQueueVisitor(executor);
+    counter.enqueue();
+    try {
+      counter.work(false);
+      fail();
+    } catch (Error expected) {
+      assertEquals("Could not create thread (fakeout)", expected.getMessage());
+    }
+    assertSame(5, counter.getCount());
+
+    executor.shutdown();
+    assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS));
+  }
+
+  // Regression test to make sure that AbstractQueueVisitor doesn't swallow unchecked exceptions if
+  // it is interrupted concurrently with the unchecked exception being thrown.
+  @Test
+  public void interruptAndThrownIsInterruptedAndThrown() throws Exception {
+    final ConcreteQueueVisitor visitor = new ConcreteQueueVisitor();
+    // Use a latch to make sure the thread gets a chance to start.
+    final CountDownLatch threadStarted = new CountDownLatch(1);
+    visitor.enqueue(new Runnable() {
+      @Override
+      public void run() {
+        threadStarted.countDown();
+        assertTrue(Uninterruptibles.awaitUninterruptibly(
+            visitor.getInterruptionLatchForTestingOnly(), 2, TimeUnit.SECONDS));
+        throw THROWABLE;
+      }
+    });
+    assertTrue(threadStarted.await(TestUtils.WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+    // Interrupt will not be processed until work starts.
+    Thread.currentThread().interrupt();
+    try {
+      visitor.work(/*interruptWorkers=*/true);
+      fail();
+    } catch (Exception e) {
+      assertEquals(THROWABLE, e);
+      assertTrue(Thread.interrupted());
+    }
+  }
+
+  @Test
+  public void interruptionWithoutInterruptingWorkers() throws Exception {
+    final Thread mainThread = Thread.currentThread();
+    final CountDownLatch latch1 = new CountDownLatch(1);
+    final CountDownLatch latch2 = new CountDownLatch(1);
+    final boolean[] workerThreadCompleted = { false };
+    final ConcreteQueueVisitor visitor = new ConcreteQueueVisitor();
+
+    visitor.enqueue(new Runnable() {
+      @Override
+      public void run() {
+        try {
+          latch1.countDown();
+          latch2.await();
+          workerThreadCompleted[0] = true;
+        } catch (InterruptedException e) {
+          // Do not set workerThreadCompleted to true
+        }
+      }
+    });
+
+    TestThread interrupterThread = new TestThread() {
+      @Override
+      public void runTest() throws Exception {
+        latch1.await();
+        mainThread.interrupt();
+        assertTrue(visitor.awaitInterruptionForTestingOnly(TestUtils.WAIT_TIMEOUT_MILLISECONDS,
+            TimeUnit.MILLISECONDS));
+        latch2.countDown();
+      }
+    };
+
+    interrupterThread.start();
+
+    try {
+      visitor.work(false);
+      fail();
+    } catch (InterruptedException e) {
+      // Expected.
+    }
+
+    interrupterThread.joinAndAssertState(400);
+    assertTrue(workerThreadCompleted[0]);
+  }
+
+  @Test
+  public void interruptionWithInterruptingWorkers() throws Exception {
+    assertInterruptWorkers(null);
+
+    ThreadPoolExecutor executor = new ThreadPoolExecutor(3, 3, 0, TimeUnit.SECONDS,
+                                                         new LinkedBlockingQueue<Runnable>());
+    assertInterruptWorkers(executor);
+    executor.shutdown();
+    executor.awaitTermination(TestUtils.WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+  }
+
+  private void assertInterruptWorkers(ThreadPoolExecutor executor) throws Exception {
+    final CountDownLatch latch1 = new CountDownLatch(1);
+    final CountDownLatch latch2 = new CountDownLatch(1);
+    final boolean[] workerThreadInterrupted = { false };
+    ConcreteQueueVisitor visitor = (executor == null)
+        ? new ConcreteQueueVisitor()
+        : new ConcreteQueueVisitor(executor, true);
+
+    visitor.enqueue(new Runnable() {
+      @Override
+      public void run() {
+        try {
+          latch1.countDown();
+          latch2.await();
+        } catch (InterruptedException e) {
+          workerThreadInterrupted[0] = true;
+        }
+      }
+    });
+
+    latch1.await();
+    Thread.currentThread().interrupt();
+
+    try {
+      visitor.work(true);
+      fail();
+    } catch (InterruptedException e) {
+      // Expected.
+    }
+
+    assertTrue(workerThreadInterrupted[0]);
+  }
+
+  @Test
+  public void failFast() throws Exception {
+    // In failFast mode, we only run actions queued before the exception.
+    assertFailFast(null, true, false, false, "a", "b");
+
+    // In !failFast mode, we complete all queued actions.
+    assertFailFast(null, false, false, false, "a", "b", "1", "2");
+
+    // Now check fail-fast on interrupt:
+    assertFailFast(null, false, true, true, "a", "b");
+    assertFailFast(null, false, false, true, "a", "b", "1", "2");
+  }
+
+  @Test
+  public void failFastNoShutdown() throws Exception {
+    ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 5, 0, TimeUnit.SECONDS,
+                                                         new LinkedBlockingQueue<Runnable>());
+    // In failFast mode, we only run actions queued before the exception.
+    assertFailFast(executor, true, false, false, "a", "b");
+
+    // In !failFast mode, we complete all queued actions.
+    assertFailFast(executor, false, false, false, "a", "b", "1", "2");
+
+    // Now check fail-fast on interrupt:
+    assertFailFast(executor, false, true, true, "a", "b");
+    assertFailFast(executor, false, false, true, "a", "b", "1", "2");
+
+    executor.shutdown();
+    assertTrue(executor.awaitTermination(TestUtils.WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+  }
+
+  private void assertFailFast(ThreadPoolExecutor executor,
+                              boolean failFastOnException, boolean failFastOnInterrupt,
+                              boolean interrupt, String... expectedVisited) throws Exception {
+    assertTrue(executor == null || !executor.isShutdown());
+    AbstractQueueVisitor visitor = (executor == null)
+        ? new ConcreteQueueVisitor(failFastOnException, failFastOnInterrupt)
+        : new ConcreteQueueVisitor(executor, failFastOnException, failFastOnInterrupt);
+
+    List<String> visitedList = Collections.synchronizedList(Lists.<String>newArrayList());
+
+    // Runnable "ra" will await the uncaught exception from
+    // "throwingRunnable", then add "a" to the list and
+    // enqueue "r1". Runnable "r1" should be
+    // executed iff !failFast.
+
+    CountDownLatch latchA = new CountDownLatch(1);
+    CountDownLatch latchB = new CountDownLatch(1);
+
+    Runnable r1 = awaitAddAndEnqueueRunnable(interrupt, visitor, null, visitedList, "1", null);
+    Runnable r2 = awaitAddAndEnqueueRunnable(interrupt, visitor, null, visitedList, "2", null);
+    Runnable ra = awaitAddAndEnqueueRunnable(interrupt, visitor, latchA, visitedList, "a", r1);
+    Runnable rb = awaitAddAndEnqueueRunnable(interrupt, visitor, latchB, visitedList, "b", r2);
+
+    visitor.enqueue(ra);
+    visitor.enqueue(rb);
+    latchA.await();
+    latchB.await();
+    visitor.enqueue(interrupt ? interruptingRunnable(Thread.currentThread()) : throwingRunnable());
+
+    try {
+      visitor.work(false);
+      fail();
+    } catch (Exception e) {
+      if (interrupt) {
+        assertTrue(e instanceof InterruptedException);
+      } else {
+        assertSame(THROWABLE, e);
+      }
+    }
+    assertTrue(
+        "got: " + visitedList + "\nwant: " + Arrays.toString(expectedVisited),
+        Sets.newHashSet(visitedList).equals(Sets.newHashSet(expectedVisited)));
+
+    if (executor != null) {
+      assertFalse(executor.isShutdown());
+      assertEquals(0, visitor.getTaskCount());
+    }
+  }
+
+  @Test
+  public void jobIsInterruptedWhenOtherFails() throws Exception {
+    ThreadPoolExecutor executor = new ThreadPoolExecutor(3, 3, 0, TimeUnit.SECONDS,
+        new LinkedBlockingQueue<Runnable>());
+
+    final QueueVisitorWithCriticalError visitor = new QueueVisitorWithCriticalError(executor);
+    final CountDownLatch latch1 = new CountDownLatch(1);
+    final AtomicBoolean wasInterrupted = new AtomicBoolean(false);
+
+    Runnable r1 = new Runnable() {
+
+      @Override
+      public void run() {
+        latch1.countDown();
+        try {
+          // Interruption is expected during a sleep. There is no sense in fail or assert call
+          // because exception is going to be swallowed inside AbstractQueueVisitior.
+          // We are using wasInterrupted flag to assert in the end of test.
+          Thread.sleep(1000);
+        } catch (InterruptedException e) {
+          wasInterrupted.set(true);
+        }
+      }
+    };
+
+    visitor.enqueue(r1);
+    latch1.await();
+    visitor.enqueue(throwingRunnable());
+
+    try {
+      visitor.work(true);
+      fail();
+    } catch (Exception e) {
+      assertSame(THROWABLE, e);
+    }
+
+    assertTrue(wasInterrupted.get());
+    assertTrue(executor.isShutdown());
+  }
+
+  private Runnable throwingRunnable() {
+    return new Runnable() {
+      @Override
+      public void run() {
+        throw THROWABLE;
+      }
+    };
+  }
+
+  private Runnable interruptingRunnable(final Thread thread) {
+    return new Runnable() {
+      @Override
+      public void run() {
+        thread.interrupt();
+      }
+    };
+  }
+
+  private static Runnable awaitAddAndEnqueueRunnable(final boolean interrupt,
+                                                     final AbstractQueueVisitor visitor,
+                                                     final CountDownLatch started,
+                                                     final List<String> list,
+                                                     final String toAdd,
+                                                     final Runnable toEnqueue) {
+    return new Runnable() {
+      @Override
+      public void run() {
+        if (started != null) {
+          started.countDown();
+        }
+
+        try {
+          assertTrue(interrupt
+                     ? visitor.awaitInterruptionForTestingOnly(1, TimeUnit.MINUTES)
+                     : visitor.getExceptionLatchForTestingOnly().await(1, TimeUnit.MINUTES));
+        } catch (InterruptedException e) {
+          // Unexpected.
+          throw new RuntimeException(e);
+        }
+        list.add(toAdd);
+        if (toEnqueue != null) {
+          visitor.enqueue(toEnqueue);
+        }
+      }
+    };
+  }
+
+  private static class CountingQueueVisitor extends AbstractQueueVisitor {
+
+    private final static String THREAD_NAME = "BlazeTest CountingQueueVisitor";
+
+    private int theInt = 0;
+    private final Object lock = new Object();
+
+    public CountingQueueVisitor() {
+      super(5, 5, 3L, TimeUnit.SECONDS, THREAD_NAME);
+    }
+
+    public CountingQueueVisitor(ThreadPoolExecutor executor) {
+      super(executor, false, true, true);
+    }
+
+    public void enqueue() {
+      super.enqueue(new Runnable() {
+        @Override
+        public void run() {
+          synchronized (lock) {
+            if (theInt < 10) {
+              theInt++;
+              enqueue();
+            }
+          }
+        }
+      });
+    }
+
+    public int getCount() {
+      return theInt;
+    }
+  }
+
+  private static class ConcreteQueueVisitor extends AbstractQueueVisitor {
+
+    private final static String THREAD_NAME = "BlazeTest ConcreteQueueVisitor";
+
+    public ConcreteQueueVisitor() {
+      super(5, 5, 3L, TimeUnit.SECONDS, THREAD_NAME);
+    }
+
+    public ConcreteQueueVisitor(boolean failFast) {
+      super(true, 5, 5, 3L, TimeUnit.SECONDS, failFast, THREAD_NAME);
+    }
+
+    public ConcreteQueueVisitor(boolean failFast, boolean failFastOnInterrupt) {
+      super(true, 5, 5, 3L, TimeUnit.SECONDS, failFast, failFastOnInterrupt, THREAD_NAME);
+    }
+
+    public ConcreteQueueVisitor(ThreadPoolExecutor executor, boolean failFast,
+        boolean failFastOnInterrupt) {
+      super(executor, /*shutdownOnCompletion=*/false, failFast, failFastOnInterrupt);
+    }
+
+    public ConcreteQueueVisitor(ThreadPoolExecutor executor, boolean failFast) {
+      super(executor, /*shutdownOnCompletion=*/false, failFast, true);
+    }
+  }
+
+  private static class QueueVisitorWithCriticalError extends AbstractQueueVisitor {
+
+    public QueueVisitorWithCriticalError(ThreadPoolExecutor executor) {
+      super(executor, false);
+    }
+
+    @Override
+    protected boolean isCriticalError(Throwable e) {
+      return true;
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/concurrent/MoreFuturesTest.java b/src/test/java/com/google/devtools/build/lib/concurrent/MoreFuturesTest.java
new file mode 100644
index 0000000..60f29ac
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/concurrent/MoreFuturesTest.java
@@ -0,0 +1,151 @@
+// Copyright 2014 Google Inc. 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.concurrent;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.util.concurrent.AbstractFuture;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.devtools.build.lib.testutil.TestUtils;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Tests for MoreFutures
+ */
+@RunWith(JUnit4.class)
+public class MoreFuturesTest {
+
+  private ExecutorService executorService;
+
+  @Before
+  public void setUp() throws Exception {
+    executorService = Executors.newFixedThreadPool(5);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    MoreExecutors.shutdownAndAwaitTermination(executorService, TestUtils.WAIT_TIMEOUT_SECONDS,
+        TimeUnit.SECONDS);
+
+  }
+
+  /** Test the normal path where everything is successful. */
+  @Test
+  public void allAsListOrCancelAllHappy() throws ExecutionException, InterruptedException {
+    final List<DelayedFuture> futureList = new ArrayList<>();
+    for (int i = 0; i < 5; i++) {
+      DelayedFuture future = new DelayedFuture(i);
+      executorService.execute(future);
+      futureList.add(future);
+    }
+    ListenableFuture<List<Object>> list = MoreFutures.allAsListOrCancelAll(futureList);
+    List<Object> result = list.get();
+    assertEquals(futureList.size(), result.size());
+    for (DelayedFuture delayedFuture : futureList) {
+      assertFalse(delayedFuture.wasCanceled);
+      assertFalse(delayedFuture.wasInterrupted);
+      assertNotNull(delayedFuture.get());
+      assertTrue(result.contains(delayedFuture.get()));
+    }
+  }
+
+  /** Test that if any of the futures in the list fails, we cancel all the futures immediately. */
+  @Test
+  public void allAsListOrCancelAllCancellation() throws InterruptedException {
+    final List<DelayedFuture> futureList = new ArrayList<>();
+    for (int i = 1; i < 6; i++) {
+      DelayedFuture future = new DelayedFuture(i * 1000);
+      executorService.execute(future);
+      futureList.add(future);
+    }
+    DelayedFuture toFail = new DelayedFuture(1000);
+    futureList.add(toFail);
+    toFail.makeItFail();
+    ListenableFuture<List<Object>> list = MoreFutures.allAsListOrCancelAll(futureList);
+
+    try {
+      list.get();
+      fail("This should fail");
+    } catch (InterruptedException | ExecutionException ignored) {
+    }
+    Thread.sleep(100);
+    for (DelayedFuture delayedFuture : futureList) {
+      assertTrue(delayedFuture.wasCanceled || delayedFuture == toFail);
+      assertFalse(delayedFuture.wasInterrupted);
+    }
+  }
+
+  /**
+   * A future that (if added to an executor) waits {@code delay} milliseconds before setting a
+   * response.
+   */
+  private static class DelayedFuture extends AbstractFuture<Object> implements Runnable {
+
+    private final int delay;
+    private final CountDownLatch latch = new CountDownLatch(1);
+    private boolean wasCanceled;
+    private boolean wasInterrupted;
+
+    public DelayedFuture(int delay) {
+      this.delay = delay;
+    }
+
+    @Override
+    public void run() {
+      try {
+        wasCanceled = latch.await(delay, TimeUnit.MILLISECONDS);
+        // Not canceled and not done (makeItFail sets the value, so in that case is done).
+        if (!wasCanceled && !isDone()) {
+          set(new Object());
+        }
+      } catch (InterruptedException e) {
+        wasInterrupted = true;
+      }
+    }
+
+    public void makeItFail() {
+      setException(new RuntimeException("I like to fail!!"));
+      latch.countDown();
+    }
+
+    @Override
+    public boolean cancel(boolean mayInterruptIfRunning) {
+      return super.cancel(mayInterruptIfRunning);
+    }
+
+    @Override
+    protected void interruptTask() {
+      latch.countDown();
+    }
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/concurrent/ThreadSafetyTest.java b/src/test/java/com/google/devtools/build/lib/concurrent/ThreadSafetyTest.java
new file mode 100644
index 0000000..8532bae
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/concurrent/ThreadSafetyTest.java
@@ -0,0 +1,313 @@
+// Copyright 2014 Google Inc. 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.concurrent;
+
+import com.google.devtools.build.lib.concurrent.ThreadSafety.ConditionallyThreadCompatible;
+import com.google.devtools.build.lib.concurrent.ThreadSafety.ConditionallyThreadSafe;
+import com.google.devtools.build.lib.concurrent.ThreadSafety.Immutable;
+import com.google.devtools.build.lib.concurrent.ThreadSafety.ThreadCompatible;
+import com.google.devtools.build.lib.concurrent.ThreadSafety.ThreadHostile;
+import com.google.devtools.build.lib.concurrent.ThreadSafety.ThreadSafe;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Random;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * This file just contains some examples of the use of
+ * annotations for different categories of thread safety:
+ *   ThreadSafe
+ *   ThreadCompatible
+ *   ThreadHostile
+ *   Immutable ThreadSafe
+ *   Immutable ThreadHostile
+ *
+ * It doesn't really test much -- just that this code
+ * using those annotations compiles and runs.
+ *
+ * The main class here is annotated as being both ConditionallyThreadSafe
+ * and ConditionallyThreadCompatible, and accordingly we document here the
+ * conditions under which it is thread-safe and thread-compatible:
+ *    - it is thread-safe if you only use the testThreadSafety() method,
+ *      the ThreadSafeCounter class, and/or ImmutableThreadSafeCounter class;
+ *    - it is thread-compatible if you use only those and/or the
+ *      ThreadCompatibleCounter and/or ImmutableThreadCompatibleCounter class;
+ *    - it is thread-hostile otherwise.
+ */
+@ConditionallyThreadSafe @ConditionallyThreadCompatible
+@RunWith(JUnit4.class)
+public class ThreadSafetyTest {
+
+  @ThreadSafe
+  public static final class ThreadSafeCounter {
+
+    // A ThreadSafe class can have public mutable fields,
+    // provided they are atomic or volatile.
+
+    public volatile boolean myBool;
+    public AtomicInteger myInt;
+
+    // A ThreadSafe class can have private mutable fields,
+    // provided that access to them is synchronized.
+    private int value;
+    public ThreadSafeCounter(int value) {
+      synchronized (this) { // is this needed?
+        this.value = value;
+      }
+    }
+    public synchronized int getValue() {
+      return value;
+    }
+    public synchronized void increment() {
+      value++;
+    }
+
+    // A ThreadSafe class can have private mutable members
+    // provided that the methods of the class synchronize access
+    // to them.
+    // These members could be static...
+    private static int numFoos = 0;
+    public static synchronized void foo() {
+      numFoos++;
+    }
+    public static synchronized int getNumFoos() {
+      return numFoos;
+    }
+    // ... or non-static.
+    private int numBars = 0;
+    public synchronized void bar() {
+      numBars++;
+    }
+    public synchronized int getNumBars() {
+      return numBars;
+    }
+  }
+
+  @ThreadCompatible
+  public static final class ThreadCompatibleCounter {
+
+    // A ThreadCompatible class can have public mutable fields.
+    public int value;
+    public ThreadCompatibleCounter(int value) {
+      this.value = value;
+    }
+    public int getValue() {
+      return value;
+    }
+    public void increment() {
+      value++;
+    }
+
+    // A ThreadCompatible class can have mutable static members
+    // provided that the methods of the class synchronize access
+    // to them.
+    private static int numFoos = 0;
+    public static synchronized void foo() {
+      numFoos++;
+    }
+    public static synchronized int getNumFoos() {
+      return numFoos;
+    }
+  }
+
+  @ThreadHostile
+  public static final class ThreadHostileCounter {
+
+    // A ThreadHostile class can have public mutable fields.
+    public int value;
+    public ThreadHostileCounter(int value) {
+      this.value = value;
+    }
+    public int getValue() {
+      return value;
+    }
+    public void increment() {
+      value++;
+    }
+
+    // A ThreadHostile class can perform unsynchronized access
+    // to mutable static data.
+    private static int numFoos = 0;
+    public static void foo() {
+      numFoos++;
+    }
+    public static int getNumFoos() {
+      return numFoos;
+    }
+  }
+
+  @Immutable @ThreadSafe
+  public static final class ImmutableThreadSafeCounter {
+
+    // An Immutable ThreadSafe class can have public fields,
+    // provided they are final and immutable.
+    public final int value;
+    public ImmutableThreadSafeCounter(int value) {
+      this.value = value;
+    }
+    public int getValue() {
+      return value;
+    }
+    public ImmutableThreadSafeCounter increment() {
+      return new ImmutableThreadSafeCounter(value + 1);
+    }
+
+    // An Immutable ThreadSafe class can have immutable static members.
+    public static final int NUM_STATIC_CACHE_ENTRIES = 3;
+    private static final ImmutableThreadSafeCounter[] staticCache =
+        new ImmutableThreadSafeCounter[] {
+          new ImmutableThreadSafeCounter(0),
+          new ImmutableThreadSafeCounter(1),
+          new ImmutableThreadSafeCounter(2)
+        };
+    public static ImmutableThreadSafeCounter makeUsingStaticCache(int value) {
+      if (value < NUM_STATIC_CACHE_ENTRIES) {
+        return staticCache[value];
+      } else {
+        return new ImmutableThreadSafeCounter(value);
+      }
+    }
+
+    // An Immutable ThreadSafe class can have private mutable members
+    // provided that the methods of the class synchronize access
+    // to them.
+    // These members could be static...
+    private static int cachedValue = 0;
+    private static ImmutableThreadSafeCounter cachedCounter =
+        new ImmutableThreadSafeCounter(0);
+    public static synchronized ImmutableThreadSafeCounter
+        makeUsingDynamicCache(int value) {
+      if (value != cachedValue) {
+        cachedValue = value;
+        cachedCounter = new ImmutableThreadSafeCounter(value);
+      }
+      return cachedCounter;
+    }
+    // ... or non-static.
+    private ImmutableThreadSafeCounter incrementCache = null;
+    public synchronized ImmutableThreadSafeCounter incrementUsingCache() {
+      if (incrementCache == null) {
+        incrementCache = new ImmutableThreadSafeCounter(value + 1);
+      }
+      return incrementCache;
+    }
+    // Methods of an Immutable class need not be deterministic.
+    private static Random random = new Random();
+    public int choose() {
+      return random.nextInt(value);
+    }
+  }
+
+  @Immutable @ThreadHostile
+  public static final class ImmutableThreadHostileCounter {
+
+    // An Immutable ThreadHostile class can have public fields,
+    // provided they are final and immutable.
+    public final int value;
+    public ImmutableThreadHostileCounter(int value) {
+      this.value = value;
+    }
+    public int getValue() {
+      return value;
+    }
+    public ImmutableThreadHostileCounter increment() {
+      return new ImmutableThreadHostileCounter(value + 1);
+    }
+
+    // An Immutable ThreadHostile class can have private mutable members,
+    // and doesn't need to synchronize access to them.
+    // These members could be static...
+    private static int cachedValue = 0;
+    private static ImmutableThreadHostileCounter cachedCounter =
+        new ImmutableThreadHostileCounter(0);
+    public static ImmutableThreadHostileCounter
+        makeUsingDynamicCache(int value) {
+      if (value != cachedValue) {
+        cachedValue = value;
+        cachedCounter = new ImmutableThreadHostileCounter(value);
+      }
+      return cachedCounter;
+    }
+    // ... or non-static.
+    private ImmutableThreadHostileCounter incrementCache = null;
+    public ImmutableThreadHostileCounter incrementUsingCache() {
+      if (incrementCache == null) {
+        incrementCache = new ImmutableThreadHostileCounter(value + 1);
+      }
+      return incrementCache;
+    }
+  }
+
+  @Test
+  public void threadSafety() throws InterruptedException {
+    final ThreadSafeCounter threadSafeCounterArray[] =
+        new ThreadSafeCounter[] {
+          new ThreadSafeCounter(1),
+          new ThreadSafeCounter(2),
+          new ThreadSafeCounter(3)
+        };
+    final ThreadCompatibleCounter threadCompatibleCounterArray[] =
+        new ThreadCompatibleCounter[] {
+          new ThreadCompatibleCounter(1),
+          new ThreadCompatibleCounter(2),
+          new ThreadCompatibleCounter(3)
+        };
+    final ThreadHostileCounter threadHostileCounter =
+        new ThreadHostileCounter(1);
+
+    class MyThread implements Runnable {
+
+      ThreadCompatibleCounter threadCompatibleCounter =
+          new ThreadCompatibleCounter(1);
+
+      @Override
+      public void run() {
+
+        // ThreadSafe objects can be accessed with without synchronization
+        for (ThreadSafeCounter counter : threadSafeCounterArray) {
+          counter.increment();
+        }
+
+        // ThreadCompatible objects can be accessed with without
+        // synchronization if they are thread-local
+        threadCompatibleCounter.increment();
+
+        // Access to ThreadCompatible objects must be synchronized
+        // if they could be concurrently accessed by other threads
+        for (ThreadCompatibleCounter counter : threadCompatibleCounterArray) {
+          synchronized (counter) {
+            counter.increment();
+          }
+        }
+
+        // Access to ThreadHostile objects must be synchronized.
+        synchronized (this.getClass()) {
+          threadHostileCounter.increment();
+        }
+
+      }
+    }
+
+    Thread thread1 = new Thread(new MyThread());
+    Thread thread2 = new Thread(new MyThread());
+    thread1.start();
+    thread2.start();
+    thread1.join();
+    thread2.join();
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/events/AbstractEventHandlerTest.java b/src/test/java/com/google/devtools/build/lib/events/AbstractEventHandlerTest.java
new file mode 100644
index 0000000..7033f17
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/events/AbstractEventHandlerTest.java
@@ -0,0 +1,47 @@
+// Copyright 2014 Google Inc. 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.events;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Set;
+
+/**
+ * Tests {@link AbstractEventHandler}.
+ */
+@RunWith(JUnit4.class)
+public class AbstractEventHandlerTest {
+
+  private static AbstractEventHandler create(Set<EventKind> mask) {
+    return new AbstractEventHandler(mask) {
+        @Override
+        public void handle(Event event) {}
+      };
+  }
+
+  @Test
+  public void retainsEventMask() {
+    assertEquals(EventKind.ALL_EVENTS,
+                 create(EventKind.ALL_EVENTS).getEventMask());
+    assertEquals(EventKind.ERRORS_AND_WARNINGS,
+                 create(EventKind.ERRORS_AND_WARNINGS).getEventMask());
+    assertEquals(EventKind.ERRORS,
+                 create(EventKind.ERRORS).getEventMask());
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/events/EventCollectorTest.java b/src/test/java/com/google/devtools/build/lib/events/EventCollectorTest.java
new file mode 100644
index 0000000..332afac
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/events/EventCollectorTest.java
@@ -0,0 +1,67 @@
+// Copyright 2014 Google Inc. 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.events;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+
+import com.google.devtools.build.lib.events.Event;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+
+/**
+ * Tests the {@link EventCollector} class.
+ */
+@RunWith(JUnit4.class)
+public class EventCollectorTest extends EventTestTemplate {
+
+  @Test
+  public void usesPassedInCollection() {
+    Collection<Event> events = new ArrayList<>();
+    EventCollector collector =
+        new EventCollector(EventKind.ERRORS_AND_WARNINGS, events);
+    collector.handle(event);
+    Event onlyEvent = events.iterator().next();
+    assertEquals(event.getMessage(), onlyEvent.getMessage());
+    assertSame(location, onlyEvent.getLocation());
+    assertEquals(event.getKind(), onlyEvent.getKind());
+    assertEquals(event.getLocation().getStartOffset(),
+        onlyEvent.getLocation().getStartOffset());
+    assertEquals(collector.count(), 1);
+    assertEquals(events.size(), 1);
+  }
+
+  @Test
+  public void collectsEvents() {
+    EventCollector collector =
+        new EventCollector(EventKind.ERRORS_AND_WARNINGS);
+    collector.handle(event);
+    Iterator<Event> collectedEventIt = collector.iterator();
+    Event onlyEvent = collectedEventIt.next();
+    assertEquals(event.getMessage(), onlyEvent.getMessage());
+    assertSame(location, onlyEvent.getLocation());
+    assertEquals(event.getKind(), onlyEvent.getKind());
+    assertEquals(event.getLocation().getStartOffset(),
+        onlyEvent.getLocation().getStartOffset());
+    assertFalse(collectedEventIt.hasNext());
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/events/EventSensorTest.java b/src/test/java/com/google/devtools/build/lib/events/EventSensorTest.java
new file mode 100644
index 0000000..3c97b37
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/events/EventSensorTest.java
@@ -0,0 +1,74 @@
+// Copyright 2014 Google Inc. 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.events;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * A test for {@link EventSensor}.
+ */
+@RunWith(JUnit4.class)
+public class EventSensorTest extends EventTestTemplate {
+
+  @Test
+  public void sensorStartsOutWithFalse() {
+    assertFalse(new EventSensor(EventKind.ALL_EVENTS).wasTriggered());
+    assertFalse(new EventSensor(EventKind.ERRORS).wasTriggered());
+    assertFalse(new EventSensor(EventKind.ERRORS_AND_WARNINGS).wasTriggered());
+  }
+
+  @Test
+  public void sensorNoticesEventsInItsMask() {
+    EventSensor sensor = new EventSensor(EventKind.ERRORS);
+    Reporter reporter = new Reporter(sensor);
+    reporter.handle(Event.error(location, "An ERROR event."));
+    assertTrue(sensor.wasTriggered());
+  }
+
+  @Test
+  public void sensorNoticesEventsInItsMask2() {
+    EventSensor sensor = new EventSensor(EventKind.ALL_EVENTS);
+    Reporter reporter = new Reporter(sensor);
+    reporter.handle(Event.error(location, "An ERROR event."));
+    reporter.handle(Event.warn(location, "A warning event."));
+    assertTrue(sensor.wasTriggered());
+  }
+
+  @Test
+  public void sensorIgnoresEventsNotInItsMask() {
+    EventSensor sensor = new EventSensor(EventKind.ERRORS_AND_WARNINGS);
+    Reporter reporter = new Reporter(sensor);
+    reporter.handle(Event.info(location, "An INFO event."));
+    assertFalse(sensor.wasTriggered());
+  }
+
+  @Test
+  public void sensorCanCount() {
+    EventSensor sensor = new EventSensor(EventKind.ERRORS_AND_WARNINGS);
+    Reporter reporter = new Reporter(sensor);
+    reporter.handle(Event.error(location, "An ERROR event."));
+    reporter.handle(Event.error(location, "Another ERROR event."));
+    reporter.handle(Event.warn(location, "A warning event."));
+    reporter.handle(Event.info(location, "An info event.")); // not in mask
+    assertEquals(3, sensor.getTriggerCount());
+    assertTrue(sensor.wasTriggered());
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/events/EventTest.java b/src/test/java/com/google/devtools/build/lib/events/EventTest.java
new file mode 100644
index 0000000..50fe88a
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/events/EventTest.java
@@ -0,0 +1,44 @@
+// Copyright 2014 Google Inc. 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.events;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * A super simple little test for the {@link Event} class.
+ */
+@RunWith(JUnit4.class)
+public class EventTest extends EventTestTemplate {
+
+  @Test
+  public void eventRetainsEventKind() {
+    assertEquals(EventKind.WARNING, event.getKind());
+  }
+
+  @Test
+  public void eventRetainsMessage() {
+    assertEquals("This is not an error message.", event.getMessage());
+  }
+
+  @Test
+  public void eventRetainsLocation() {
+    assertEquals(21, event.getLocation().getStartOffset());
+    assertEquals(31, event.getLocation().getEndOffset());
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/events/EventTestTemplate.java b/src/test/java/com/google/devtools/build/lib/events/EventTestTemplate.java
new file mode 100644
index 0000000..612cdf0
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/events/EventTestTemplate.java
@@ -0,0 +1,46 @@
+// Copyright 2014 Google Inc. 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.events;
+
+import com.google.devtools.build.lib.events.Location.LineAndColumn;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.util.FsApparatus;
+
+import org.junit.Before;
+
+public abstract class EventTestTemplate {
+
+  protected Event event;
+  protected Path path;
+  protected Location location;
+  protected Location locationNoPath;
+  protected Location locationNoLineInfo;
+
+  private FsApparatus scratch = FsApparatus.newInMemory();
+
+  @Before
+  public void setUp() throws Exception {
+    String message = "This is not an error message.";
+    path = scratch.path("/my/sample/path.txt");
+
+    location = Location.fromPathAndStartColumn(path, 21, 31, new LineAndColumn(3, 4));
+
+    event = new Event(EventKind.WARNING, location, message);
+
+    locationNoPath = Location.fromPathAndStartColumn(null, 21, 31, new LineAndColumn(3, 4));
+
+    locationNoLineInfo = Location.fromFileAndOffsets(path, 21, 31);
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/events/LocationTest.java b/src/test/java/com/google/devtools/build/lib/events/LocationTest.java
new file mode 100644
index 0000000..a585b0c
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/events/LocationTest.java
@@ -0,0 +1,36 @@
+// Copyright 2014 Google Inc. 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.events;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class LocationTest extends EventTestTemplate {
+
+  @Test
+  public void fromFile() throws Exception {
+    Location location = Location.fromFile(path);
+    assertEquals(path.asFragment(), location.getPath());
+    assertEquals(0, location.getStartOffset());
+    assertEquals(0, location.getEndOffset());
+    assertNull(location.getStartLineAndColumn());
+    assertNull(location.getEndLineAndColumn());
+    assertEquals(path + ":1", location.print());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/events/PrintingEventHandlerTest.java b/src/test/java/com/google/devtools/build/lib/events/PrintingEventHandlerTest.java
new file mode 100644
index 0000000..4cdfcb4
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/events/PrintingEventHandlerTest.java
@@ -0,0 +1,43 @@
+// Copyright 2014 Google Inc. 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.events;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.devtools.build.lib.testutil.MoreAsserts;
+import com.google.devtools.build.lib.util.io.RecordingOutErr;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests the {@link PrintingEventHandler}.
+ */
+@RunWith(JUnit4.class)
+public class PrintingEventHandlerTest extends EventTestTemplate {
+
+  @Test
+  public void collectsEvents() {
+    RecordingOutErr recordingOutErr = new RecordingOutErr();
+    PrintingEventHandler handler = new PrintingEventHandler(EventKind.ERRORS_AND_WARNINGS);
+    handler.setOutErr(recordingOutErr);
+    handler.handle(event);
+    MoreAsserts.assertEqualsUnifyingLineEnds("WARNING: /my/sample/path.txt:3:4: "
+                 + "This is not an error message.\n",
+                 recordingOutErr.errAsLatin1());
+    assertEquals("", recordingOutErr.outAsLatin1());
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/events/ReporterStreamTest.java b/src/test/java/com/google/devtools/build/lib/events/ReporterStreamTest.java
new file mode 100644
index 0000000..092d940
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/events/ReporterStreamTest.java
@@ -0,0 +1,68 @@
+// Copyright 2014 Google Inc. 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.events;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.devtools.build.lib.testutil.MoreAsserts;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.PrintWriter;
+
+@RunWith(JUnit4.class)
+public class ReporterStreamTest {
+
+  private Reporter reporter;
+  private StringBuilder out;
+  private EventHandler outAppender;
+
+  @Before
+  public void setUp() throws Exception {
+    reporter = new Reporter();
+    out = new StringBuilder();
+    outAppender = new EventHandler() {
+      @Override
+      public void handle(Event event) {
+        out.append("[" + event.getKind() + ": " + event.getMessage() + "]\n");
+      }
+    };
+  }
+
+  @Test
+  public void reporterStream() throws Exception {
+    assertEquals("", out.toString());
+    reporter.addHandler(outAppender);
+    PrintWriter infoWriter = new PrintWriter(new ReporterStream(reporter, EventKind.INFO), true);
+    PrintWriter warnWriter = new PrintWriter(new ReporterStream(reporter, EventKind.WARNING), true);
+    try {
+      infoWriter.println("some info");
+      warnWriter.println("a warning");
+    } finally {
+      infoWriter.close();
+      warnWriter.close();
+    }
+    reporter.getOutErr().printOutLn("some output");
+    reporter.getOutErr().printErrLn("an error");
+    MoreAsserts.assertEqualsUnifyingLineEnds(
+        "[INFO: some info\n]\n"
+            + "[WARNING: a warning\n]\n"   
+            + "[STDOUT: some output\n]\n"
+            + "[STDERR: an error\n]\n",
+            out.toString());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/events/ReporterTest.java b/src/test/java/com/google/devtools/build/lib/events/ReporterTest.java
new file mode 100644
index 0000000..f51451a
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/events/ReporterTest.java
@@ -0,0 +1,100 @@
+// Copyright 2014 Google Inc. 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.events;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests the {@link Reporter} class.
+ */
+@RunWith(JUnit4.class)
+public class ReporterTest extends EventTestTemplate {
+
+  private Reporter reporter;
+  private StringBuilder out;
+  private AbstractEventHandler outAppender;
+
+  @Before
+  public void setUp() throws Exception {
+    super.setUp();
+    reporter = new Reporter();
+    out = new StringBuilder();
+    outAppender = new AbstractEventHandler(EventKind.ERRORS) {
+      @Override
+      public void handle(Event event) {
+        out.append(event.getMessage());
+      }
+    };
+  }
+
+  @Test
+  public void reporterShowOutput() {
+    reporter.setOutputFilter(OutputFilter.RegexOutputFilter.forRegex("naughty"));
+    EventCollector collector = new EventCollector(EventKind.ALL_EVENTS);
+    reporter.addHandler(collector);
+    Event interesting = new Event(EventKind.WARNING, null, "show-me", "naughty");
+
+    reporter.handle(interesting);
+    reporter.handle(new Event(EventKind.WARNING, null, "ignore-me", "good"));
+
+    assertEquals(ImmutableList.copyOf(collector.iterator()), ImmutableList.of(interesting));
+  }
+
+  @Test
+  public void reporterCollectsEvents() {
+    ImmutableList<Event> want = ImmutableList.of(Event.warn("xyz"), Event.error("err"));
+    EventCollector collector = new EventCollector(EventKind.ALL_EVENTS);
+    reporter.addHandler(collector);
+    for (Event e : want) {
+      reporter.handle(e);
+    }
+    ImmutableList<Event> got = ImmutableList.copyOf(collector.iterator());
+    assertEquals(got, want);
+  }
+
+  @Test
+  public void reporterCopyConstructorCopiesHandlersList() {
+    reporter.addHandler(outAppender);
+    reporter.addHandler(outAppender);
+    Reporter copiedReporter = new Reporter(reporter);
+    copiedReporter.addHandler(outAppender); // Should have 3 handlers now.
+    reporter.addHandler(outAppender);
+    reporter.addHandler(outAppender); // Should have 4 handlers now.
+    copiedReporter.handle(Event.error(location, "."));
+    assertEquals("...", out.toString()); // The copied reporter has 3 handlers.
+    out = new StringBuilder();
+    reporter.handle(Event.error(location, "."));
+    assertEquals("....", out.toString()); // The old reporter has 4 handlers.
+  }
+
+  @Test
+  public void removeHandlerUndoesAddHandler() {
+    assertEquals("", out.toString());
+    reporter.addHandler(outAppender);
+    reporter.handle(Event.error(location, "Event gets registered."));
+    assertEquals("Event gets registered.", out.toString());
+    out = new StringBuilder();
+    reporter.removeHandler(outAppender);
+    reporter.handle(Event.error(location, "Event gets ignored."));
+    assertEquals("", out.toString());
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/events/SimpleReportersTest.java b/src/test/java/com/google/devtools/build/lib/events/SimpleReportersTest.java
new file mode 100644
index 0000000..deed44f
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/events/SimpleReportersTest.java
@@ -0,0 +1,47 @@
+// Copyright 2014 Google Inc. 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.events;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * A test for {@link Reporter}.
+ */
+@RunWith(JUnit4.class)
+public class SimpleReportersTest extends EventTestTemplate {
+
+  private int handlerCount = 0;
+
+  @Test
+  public void addsHandlers() {
+    EventHandler handler = new EventHandler() {
+      @Override
+      public void handle(Event event) {
+        handlerCount++;
+      }
+
+    };
+
+    Reporter reporter = new Reporter(handler);
+    reporter.handle(new Event(EventKind.INFO, location, "Add to handlerCount."));
+    reporter.handle(new Event(EventKind.INFO, location, "Add to handlerCount."));
+    reporter.handle(new Event(EventKind.INFO, location, "Add to handlerCount."));
+    assertEquals(3, handlerCount);
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/events/StoredErrorEventHandlerTest.java b/src/test/java/com/google/devtools/build/lib/events/StoredErrorEventHandlerTest.java
new file mode 100644
index 0000000..d47cf86
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/events/StoredErrorEventHandlerTest.java
@@ -0,0 +1,75 @@
+// Copyright 2014 Google Inc. 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.events;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.List;
+
+/**
+ * Tests the {@link StoredEventHandler} class.
+ */
+@RunWith(JUnit4.class)
+public class StoredErrorEventHandlerTest {
+
+  @Test
+  public void hasErrors() {
+    StoredEventHandler eventHandler = new StoredEventHandler();
+    assertFalse(eventHandler.hasErrors());
+    eventHandler.handle(Event.warn("warning"));
+    assertFalse(eventHandler.hasErrors());
+    eventHandler.handle(Event.info("info"));
+    assertFalse(eventHandler.hasErrors());
+    eventHandler.handle(Event.error("error"));
+    assertTrue(eventHandler.hasErrors());
+  }
+
+  @Test
+  public void replayOnWithoutEvents() {
+    StoredEventHandler eventHandler = new StoredEventHandler();
+    StoredEventHandler sink = new StoredEventHandler();
+
+    eventHandler.replayOn(sink);
+    assertTrue(sink.isEmpty());
+  }
+
+  @Test
+  public void replayOn() {
+    StoredEventHandler eventHandler = new StoredEventHandler();
+    StoredEventHandler sink = new StoredEventHandler();
+
+    List<Event> events = ImmutableList.of(
+        Event.warn("a"),
+        Event.error("b"),
+        Event.info("c"),
+        Event.warn("d"));
+    for (Event e : events) {
+      eventHandler.handle(e);
+    }
+
+    eventHandler.replayOn(sink);
+    assertEquals(events.size(), sink.getEvents().size());
+    for (int i = 0; i < events.size(); i++) {
+      assertEquals(events.get(i), sink.getEvents().get(i));
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/events/WarningsAsErrorsEventHandlerTest.java b/src/test/java/com/google/devtools/build/lib/events/WarningsAsErrorsEventHandlerTest.java
new file mode 100644
index 0000000..ce6b1a9
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/events/WarningsAsErrorsEventHandlerTest.java
@@ -0,0 +1,47 @@
+// Copyright 2014 Google Inc. 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.events;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests the {@link StoredEventHandler} class.
+ */
+@RunWith(JUnit4.class)
+public class WarningsAsErrorsEventHandlerTest {
+
+  @Test
+  public void hasErrors() {
+    ErrorSensingEventHandler delegate =
+        new ErrorSensingEventHandler(NullEventHandler.INSTANCE);
+    WarningsAsErrorsEventHandler eventHandler =
+        new WarningsAsErrorsEventHandler(delegate);
+
+    eventHandler.handle(Event.info("info"));
+    assertFalse(delegate.hasErrors());
+
+    eventHandler.handle(Event.warn("warning"));
+    assertTrue(delegate.hasErrors());
+
+    delegate.resetErrors();
+
+    eventHandler.handle(Event.error("error"));
+    assertTrue(delegate.hasErrors());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/events/util/EventCollectionApparatus.java b/src/test/java/com/google/devtools/build/lib/events/util/EventCollectionApparatus.java
new file mode 100644
index 0000000..1513735
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/events/util/EventCollectionApparatus.java
@@ -0,0 +1,149 @@
+// Copyright 2014 Google Inc. 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.events.util;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import com.google.devtools.build.lib.events.Event;
+import com.google.devtools.build.lib.events.EventCollector;
+import com.google.devtools.build.lib.events.EventHandler;
+import com.google.devtools.build.lib.events.EventKind;
+import com.google.devtools.build.lib.events.PrintingEventHandler;
+import com.google.devtools.build.lib.events.Reporter;
+import com.google.devtools.build.lib.testutil.JunitTestUtils;
+import com.google.devtools.build.lib.util.io.OutErr;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * An apparatus for reporting / collecting events. 
+ */
+public class EventCollectionApparatus {
+
+  /**
+   * The fail fast handler, which fails the test fail whenever we encounter
+   * an error event.
+   */
+  private static final EventHandler FAIL_FAST_HANDLER = new EventHandler() {
+    @Override
+    public void handle(Event event) {
+      assertWithMessage(event.toString()).that(EventKind.ERRORS_AND_WARNINGS)
+          .doesNotContain(event.getKind());
+    }
+  };
+  private Set<EventKind> customMask;
+  
+  /*
+  *  Determine which events the {@link #collector()} created by this apparatus
+   * will collect. Default: {@link EventKind#ERRORS_AND_WARNINGS}.
+   *
+  */
+  public EventCollectionApparatus(Set<EventKind> mask) {
+    this.customMask = mask;
+    
+    eventCollector = new EventCollector(customMask);
+    reporter = new Reporter(eventCollector);
+    printingEventHandler = new PrintingEventHandler(EventKind.ERRORS_AND_WARNINGS_AND_OUTPUT);
+    reporter.addHandler(printingEventHandler);
+    
+    this.setFailFast(true);
+  }
+  
+  public EventCollectionApparatus() {
+    this(EventKind.ERRORS_AND_WARNINGS);
+  }
+  
+  /* ---- Settings for the apparatus (configuration for creating state) ---- */
+
+  /* ---------- State that the apparatus initializes / operates on --------- */
+  private EventCollector eventCollector;
+  private Reporter reporter;
+  private PrintingEventHandler printingEventHandler;
+
+  /**
+   * Determine whether the {#link reporter()} created by this apparatus will
+   * fail fast, that is, throw an exception whenever we encounter an event of
+   * matching {@link EventKind#ERRORS_AND_WARNINGS}.
+   * Default: {@code true}.
+   */
+  public void setFailFast(boolean failFast) {
+    if (failFast) {
+      reporter.addHandler(FAIL_FAST_HANDLER);
+    } else {
+      reporter.removeHandler(FAIL_FAST_HANDLER);
+    }
+  }
+  
+  /**
+   * Initializes the apparatus (if it's not been initialized yet) and returns
+   * the reporter created with the settings specified by this apparatus.
+   */
+  public Reporter reporter() {
+    return reporter;
+  }
+
+  /**
+   * Initializes the apparatus (if it's not been initialized yet) and returns
+   * the collector created with the settings specified by this apparatus.
+   */
+  public EventCollector collector() {
+    return eventCollector;
+  }
+
+  /**
+   * Redirects all output to the specified OutErr stream pair.
+   * Returns the previous OutErr.
+   */
+  public OutErr setOutErr(OutErr outErr) {
+    return printingEventHandler.setOutErr(outErr);
+  }
+
+  /**
+   * Utility method: Asserts that the {@link #collector()} has not collected
+   * any events.
+   *
+   * @throws IllegalStateException If the apparatus has not yet been
+   *    initialized by calling {@link #reporter()} or {@link #collector()}.
+   */
+  public void assertNoEvents() {
+    JunitTestUtils.assertNoEvents(eventCollector);
+  }
+
+  /**
+   * Utility method: Assert that the {@link #collector()} has received an
+   * event with the {@code expectedMessage}.
+   */
+  public Event assertContainsEvent(String expectedMessage) {
+    return JunitTestUtils.assertContainsEvent(eventCollector,
+                                              expectedMessage);
+  }
+
+  public List<Event> assertContainsEventWithFrequency(String expectedMessage,
+      int expectedFrequency) {
+    return JunitTestUtils.assertContainsEventWithFrequency(eventCollector, expectedMessage,
+        expectedFrequency);
+  }
+
+  /**
+   * Utility method: Assert that the {@link #collector()} has received an
+   * event with the {@code expectedMessage} in quotes.
+   */
+
+  public Event assertContainsEventWithWordsInQuotes(String... words) {
+    return JunitTestUtils.assertContainsEventWithWordsInQuotes(
+        eventCollector, words);
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/events/util/LocationTestingUtil.java b/src/test/java/com/google/devtools/build/lib/events/util/LocationTestingUtil.java
new file mode 100644
index 0000000..66aaf30
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/events/util/LocationTestingUtil.java
@@ -0,0 +1,35 @@
+// Copyright 2014 Google Inc. 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.events.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.devtools.build.lib.events.Location;
+
+/**
+ * Static utility methods for testing Locations.
+ */
+public class LocationTestingUtil {
+
+  private LocationTestingUtil() {
+  }
+
+  public static void assertEqualLocations(Location expected, Location actual) {
+    assertThat(actual.getStartOffset()).isEqualTo(expected.getStartOffset());
+    assertThat(actual.getStartLineAndColumn()).isEqualTo(expected.getStartLineAndColumn());
+    assertThat(actual.getEndOffset()).isEqualTo(expected.getEndOffset());
+    assertThat(actual.getEndLineAndColumn()).isEqualTo(expected.getEndLineAndColumn());
+    assertThat(actual.getPath()).isEqualTo(expected.getPath());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/BlazeTestSuiteBuilder.java b/src/test/java/com/google/devtools/build/lib/testutil/BlazeTestSuiteBuilder.java
new file mode 100644
index 0000000..d2f36a6
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/BlazeTestSuiteBuilder.java
@@ -0,0 +1,123 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import com.google.common.base.Predicate;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * A base class for constructing test suites by searching the classpath for
+ * tests, possibly restricted to a predicate.
+ */
+public class BlazeTestSuiteBuilder {
+
+  /**
+   * @return a TestSuiteBuilder configured for Blaze.
+   */
+  protected TestSuiteBuilder getBuilder() {
+    return new TestSuiteBuilder()
+        .addPackageRecursive("com.google.devtools.build.lib");
+  }
+
+  /** A predicate that succeeds only for LARGE tests. */
+  public static final Predicate<Class<?>> TEST_IS_LARGE =
+      hasSize(Suite.LARGE_TESTS);
+
+  /** A predicate that succeeds only for MEDIUM tests. */
+  public static final Predicate<Class<?>> TEST_IS_MEDIUM =
+      hasSize(Suite.MEDIUM_TESTS);
+
+  /** A predicate that succeeds only for SMALL tests. */
+  public static final Predicate<Class<?>> TEST_IS_SMALL =
+      hasSize(Suite.SMALL_TESTS);
+
+  /** A predicate that succeeds only for non-flaky tests. */
+  public static final Predicate<Class<?>> TEST_IS_FLAKY = new Predicate<Class<?>>() {
+    @Override
+    public boolean apply(Class<?> testClass) {
+      return Suite.isFlaky(testClass);
+    }
+  };
+
+  private static Predicate<Class<?>> hasSize(final Suite size) {
+    return new Predicate<Class<?>>() {
+      @Override
+      public boolean apply(Class<?> testClass) {
+        return Suite.getSize(testClass) == size;
+      }
+    };
+  }
+
+  protected static Predicate<Class<?>> inSuite(final String suiteName) {
+    return new Predicate<Class<?>>() {
+      @Override
+      public boolean apply(Class<?> testClass) {
+        return Suite.getSuiteName(testClass).equalsIgnoreCase(suiteName);
+      }
+    };
+  }
+
+  /**
+   * Given a TestCase subclass, returns its designated suite annotation, if
+   * any, or the empty string otherwise.
+   */
+  public static String getSuite(Class<?> clazz) {
+    TestSpec spec = clazz.getAnnotation(TestSpec.class);
+    return spec == null ? "" : spec.suite();
+  }
+
+  /**
+   * Returns a predicate over TestCases that is true iff the TestCase has a
+   * TestSpec annotation whose suite="..." value (a comma-separated list of
+   * tags) matches all of the query operators specified in the system property
+   * {@code blaze.suite}.  The latter is also a comma-separated list, but of
+   * query operators, each of which is either the name of a tag which must be
+   * present (e.g. "foo"), or the !-prefixed name of a tag that must be absent
+   * (e.g. "!foo").
+   */
+  public static Predicate<Class<?>> matchesSuiteQuery() {
+    final String suiteProperty = System.getProperty("blaze.suite");
+    if (suiteProperty == null) {
+      throw new IllegalArgumentException("blaze.suite property not found");
+    }
+    final Set<String> queryTokens = splitCommas(suiteProperty);
+    return new Predicate<Class<?>>() {
+      @Override
+      public boolean apply(Class<?> testClass) {
+        // Return true iff every queryToken is satisfied by suiteTags.
+        Set<String> suiteTags = splitCommas(getSuite(testClass));
+        for (String queryToken : queryTokens) {
+          if (queryToken.startsWith("!")) { // forbidden tag
+            if (suiteTags.contains(queryToken.substring(1))) {
+              return false;
+            }
+          } else { // mandatory tag
+            if (!suiteTags.contains(queryToken)) {
+              return false;
+            }
+          }
+        }
+        return true;
+      }
+    };
+  }
+
+  private static Set<String> splitCommas(String s) {
+    return new HashSet<>(Arrays.asList(s.split(",")));
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/BlazeTestUtils.java b/src/test/java/com/google/devtools/build/lib/testutil/BlazeTestUtils.java
new file mode 100644
index 0000000..8588a08
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/BlazeTestUtils.java
@@ -0,0 +1,118 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Preconditions;
+import com.google.devtools.build.lib.analysis.BlazeDirectories;
+import com.google.devtools.build.lib.analysis.config.BinTools;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.Path;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+
+/**
+ * Some static utility functions for testing Blaze code. In contrast to {@link TestUtils}, these
+ * functions are Blaze-specific.
+ */
+public class BlazeTestUtils {
+  private BlazeTestUtils() {}
+
+  /**
+   * Populates the _embedded_binaries/ directory, containing all binaries/libraries, by symlinking
+   * directories#getEmbeddedBinariesRoot() to the test's runfiles tree.
+   */
+  public static BinTools getIntegrationBinTools(BlazeDirectories directories) throws IOException {
+    Path embeddedDir = directories.getEmbeddedBinariesRoot();
+    FileSystemUtils.createDirectoryAndParents(embeddedDir);
+
+    Path runfiles = directories.getFileSystem().getPath(BlazeTestUtils.runfilesDir());
+    // Copy over everything in embedded_scripts.
+    Path embeddedScripts = runfiles.getRelative(TestConstants.EMBEDDED_SCRIPTS_PATH);
+    Collection<Path> files = new ArrayList<Path>();
+    if (embeddedScripts.exists()) {
+      files.addAll(embeddedScripts.getDirectoryEntries());
+    } else {
+      System.err.println("test does not have " + embeddedScripts);
+    }
+
+    for (Path fromFile : files) {
+      try {
+        embeddedDir.getChild(fromFile.getBaseName()).createSymbolicLink(fromFile);
+      } catch (IOException e) {
+        System.err.println("Could not symlink: " + e.getMessage());
+      }
+    }
+
+    return BinTools.forIntegrationTesting(
+        directories, embeddedDir.toString(), TestConstants.EMBEDDED_TOOLS);
+  }
+
+  /**
+   * Writes a FilesetRule to a String array.
+   *
+   * @param name the name of the rule.
+   * @param out the output directory.
+   * @param entries The FilesetEntry entries.
+   * @return the String array of the rule.  One String for each line.
+   */
+  public static String[] createFilesetRule(String name, String out, String... entries) {
+    return new String[] {
+        String.format("Fileset(name = '%s', out = '%s',", name, out),
+                      "        entries = [" +  Joiner.on(", ").join(entries) + "])"
+    };
+  }
+
+  public static File undeclaredOutputDir() {
+    String dir = System.getenv("TEST_UNDECLARED_OUTPUTS_DIR");
+    if (dir != null) {
+      return new File(dir);
+    }
+
+    return TestUtils.tmpDirFile();
+  }
+
+  public static String runfilesDir() {
+    String runfilesDirStr = TestUtils.getUserValue("TEST_SRCDIR");
+    Preconditions.checkState(runfilesDirStr != null && runfilesDirStr.length() > 0,
+        "TEST_SRCDIR unset or empty");
+    return new File(runfilesDirStr).getAbsolutePath();
+  }
+
+  /** Creates an empty file, along with all its parent directories. */
+  public static void makeEmptyFile(Path path) throws IOException {
+    FileSystemUtils.createDirectoryAndParents(path.getParentDirectory());
+    FileSystemUtils.createEmptyFile(path);
+  }
+
+  /**
+   * Changes the mtime of the file "path", which must exist.  No guarantee is
+   * made about the new mtime except that it is different from the previous one.
+   *
+   * @throws IOException if the mtime could not be read or set.
+   */
+  public static void changeModtime(Path path)
+    throws IOException {
+    long prevMtime = path.getLastModifiedTime();
+    long newMtime = prevMtime;
+    do {
+      newMtime += 1000;
+      path.setLastModifiedTime(newMtime);
+    } while (path.getLastModifiedTime() == prevMtime);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/BuildRuleBuilder.java b/src/test/java/com/google/devtools/build/lib/testutil/BuildRuleBuilder.java
new file mode 100644
index 0000000..9f770c5
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/BuildRuleBuilder.java
@@ -0,0 +1,202 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import com.google.common.base.Function;
+import com.google.common.base.Joiner;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.devtools.build.lib.packages.RuleClass;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Utility for quickly creating BUILD file rules for use in tests.
+ *
+ * <p>The use case for this class is writing BUILD files where simple
+ * readability for the sake of rules' relationship to the test framework
+ * is more important than detailed semantics and layout.
+ *
+ * <p>The behavior provided by this class is not meant to be exhaustive,
+ * but should handle a majority of simple cases.
+ *
+ * <p>Example:
+ *
+ * <pre>
+ *   String text = new BuildRuleBuilder("java_library", "MyRule")
+        .setSources("First.java", "Second.java", "Third.java")
+        .setDeps(":library", "//java/com/google/common/collect")
+        .setResources("schema/myschema.xsd")
+        .build();
+ * </pre>
+ *
+ */
+public class BuildRuleBuilder {
+  protected final RuleClass ruleClass;
+  protected final String ruleName;
+  private Map<String, List<String>> multiValueAttributes;
+  private Map<String, Object> singleValueAttributes;
+  protected Map<String, RuleClass> ruleClassMap;
+  
+  /**
+   * Create a new instance.
+   *
+   * @param ruleClass the rule class of the new rule
+   * @param ruleName the name of the new rule.
+   */
+  public BuildRuleBuilder(String ruleClass, String ruleName) {
+    this(ruleClass, ruleName, getDefaultRuleClassMap());
+  }
+
+  protected static Map<String, RuleClass> getDefaultRuleClassMap() {
+    return TestRuleClassProvider.getRuleClassProvider().getRuleClassMap();
+  }
+
+  public BuildRuleBuilder(String ruleClass, String ruleName, Map<String, RuleClass> ruleClassMap) {
+    this.ruleClass = ruleClassMap.get(ruleClass);
+    this.ruleName = ruleName;
+    this.multiValueAttributes = new HashMap<>();
+    this.singleValueAttributes = new HashMap<>();
+    this.ruleClassMap = ruleClassMap;
+  }
+
+  /**
+   * Sets the value of a single valued attribute
+   */
+  public BuildRuleBuilder setSingleValueAttribute(String attrName, Object value) {
+    Preconditions.checkState(!singleValueAttributes.containsKey(attrName),
+        "attribute '" + attrName + "' already set");
+    singleValueAttributes.put(attrName, value);
+    return this;
+  }
+
+  /**
+   * Sets the value of a list type attribute
+   */
+  public BuildRuleBuilder setMultiValueAttribute(String attrName, String... value) {
+    Preconditions.checkState(!multiValueAttributes.containsKey(attrName),
+        "attribute '" + attrName + "' already set");
+    multiValueAttributes.put(attrName, Lists.newArrayList(value));
+    return this;
+  }
+
+  /**
+   * Set the srcs attribute.
+   */
+  public BuildRuleBuilder setSources(String... sources) {
+    return setMultiValueAttribute("srcs", sources);
+  }
+
+  /**
+   * Set the deps attribute.
+   */
+  public BuildRuleBuilder setDeps(String... deps) {
+    return setMultiValueAttribute("deps", deps);
+  }
+
+  /**
+   * Set the resources attribute.
+   */
+  public BuildRuleBuilder setResources(String... resources) {
+    return setMultiValueAttribute("resources", resources);
+  }
+
+  /**
+   * Set the data attribute.
+   */
+  public BuildRuleBuilder setData(String... data) {
+    return setMultiValueAttribute("data", data);
+  }
+
+  /**
+   * Generate the rule
+   *
+   * @return a string representation of the rule.
+   */
+  public String build() {
+    StringBuilder sb = new StringBuilder();
+    sb.append(ruleClass.getName()).append("(");
+    printNormal(sb, "name", ruleName);
+    for (Map.Entry<String, List<String>> entry : multiValueAttributes.entrySet()) {
+      printArray(sb, entry.getKey(), entry.getValue());
+    }
+    for (Map.Entry<String, Object> entry : singleValueAttributes.entrySet()) {
+      printNormal(sb, entry.getKey(), entry.getValue());
+    }
+    sb.append(")\n");
+    return sb.toString();
+  }
+
+  private void printArray(StringBuilder sb, String attr, List<String> values) {
+    if (values == null || values.isEmpty()) {
+      return;
+    }
+    sb.append("      ").append(attr).append(" = ");
+    printList(sb, values);
+    sb.append(",");
+    sb.append("\n");
+  }
+
+  private void printNormal(StringBuilder sb, String attr, Object value) {
+    if (value == null) {
+      return;
+    }
+    sb.append("      ").append(attr).append(" = ");
+    if (value instanceof Integer) {
+      sb.append(value);
+    } else {
+      sb.append("'").append(value).append("'");
+    }
+    sb.append(",");
+    sb.append("\n");
+  }
+
+  /**
+   * Turns iterable of {a b c} into string "['a', 'b', 'c']", appends to
+   * supplied StringBuilder.
+   */
+  private void printList(StringBuilder sb, List<String> elements) {
+    sb.append("[");
+    Joiner.on(",").appendTo(sb,
+        Iterables.transform(elements, new Function<String, String>() {
+          @Override
+          public String apply(String from) {
+            return "'" + from + "'";
+          }
+        }));
+    sb.append("]");
+  }
+
+  /**
+   * Returns the transitive closure of file names need to be generated in order
+   * for this rule to build.
+   */
+  public Collection<String> getFilesToGenerate() {
+    return ImmutableList.of();
+  }
+
+  /**
+   * Returns the transitive closure of BuildRuleBuilders need to be generated in order
+   * for this rule to build.
+   */
+  public Collection<BuildRuleBuilder> getRulesToGenerate() {
+    return ImmutableList.of();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/BuildRuleWithDefaultsBuilder.java b/src/test/java/com/google/devtools/build/lib/testutil/BuildRuleWithDefaultsBuilder.java
new file mode 100644
index 0000000..4af7ad1
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/BuildRuleWithDefaultsBuilder.java
@@ -0,0 +1,231 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import com.google.common.base.Preconditions;
+import com.google.common.base.Predicate;
+import com.google.common.base.Predicates;
+import com.google.devtools.build.lib.packages.Attribute;
+import com.google.devtools.build.lib.packages.Attribute.AllowedValueSet;
+import com.google.devtools.build.lib.packages.RuleClass;
+import com.google.devtools.build.lib.packages.Type;
+import com.google.devtools.build.lib.util.FileTypeSet;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A helper class to generate valid rules with filled attributes if necessary.
+ */
+public class BuildRuleWithDefaultsBuilder extends BuildRuleBuilder {
+
+  private Set<String> generateFiles;
+  private Map<String, BuildRuleBuilder> generateRules;
+
+  public BuildRuleWithDefaultsBuilder(String ruleClass, String ruleName) {
+    super(ruleClass, ruleName);
+    this.generateFiles = new HashSet<>();
+    this.generateRules = new HashMap<>();
+  }
+
+  private BuildRuleWithDefaultsBuilder(String ruleClass, String ruleName,
+      Map<String, RuleClass> ruleClassMap, Set<String> generateFiles,
+      Map<String, BuildRuleBuilder> generateRules) {
+    super(ruleClass, ruleName, ruleClassMap);
+    this.generateFiles = generateFiles;
+    this.generateRules = generateRules;
+  }
+
+  /**
+   * Creates a dummy file with the given extension in the given package and returns a valid Blaze
+   * label referring to the file. Note, the created label depends on the package of the rule.
+   */
+  private String getDummyFileLabel(String rulePkg, String filePkg, String extension,
+      Type<?> attrType) {
+    boolean isInput = (attrType == Type.LABEL || attrType == Type.LABEL_LIST);
+    String fileName = (isInput ? "dummy_input" : "dummy_output") + extension;
+    generateFiles.add(filePkg + "/" + fileName);
+    if (rulePkg.equals(filePkg)) {
+      return ":" + fileName;
+    } else {
+      return filePkg + ":" + fileName;
+    }
+  }
+
+  private String getDummyRuleLabel(String rulePkg, RuleClass referencedRuleClass) {
+    String referencedRuleName = ruleName + "_ref_" + referencedRuleClass.getName()
+        .replace("$", "").replace(":", "");
+    // The new generated rule should have the same generatedFiles and generatedRules
+    // in order to avoid duplications
+    BuildRuleWithDefaultsBuilder builder = new BuildRuleWithDefaultsBuilder(
+        referencedRuleClass.getName(), referencedRuleName, ruleClassMap, generateFiles,
+        generateRules);
+    builder.popuplateAttributes(rulePkg, true);
+    generateRules.put(referencedRuleClass.getName(), builder);
+    return referencedRuleName;
+  }
+
+  public BuildRuleWithDefaultsBuilder popuplateLabelAttribute(String pkg, Attribute attribute) {
+    return popuplateLabelAttribute(pkg, pkg, attribute);
+  }
+
+  /**
+   * Populates the label type attribute with generated values. Populates with a file if possible, or
+   * generates an appropriate rule. Note, that the rules are always generated in the same package.
+   */
+  public BuildRuleWithDefaultsBuilder popuplateLabelAttribute(String rulePkg, String filePkg,
+      Attribute attribute) {
+    Type<?> attrType = attribute.getType();
+    String label = null;
+    if (attribute.getAllowedFileTypesPredicate() != FileTypeSet.NO_FILE) {
+      // Try to populate with files first
+      String extension = null;
+      if (attribute.getAllowedFileTypesPredicate() == FileTypeSet.ANY_FILE) {
+        extension = ".txt";
+      } else {
+        FileTypeSet fileTypes = attribute.getAllowedFileTypesPredicate();
+        // This argument should always hold, if not that means a Blaze design/implementation error
+        Preconditions.checkArgument(fileTypes.getExtensions().size() > 0);
+        extension = fileTypes.getExtensions().get(0);
+      }
+      label = getDummyFileLabel(rulePkg, filePkg, extension, attrType);
+    } else {
+      Predicate<RuleClass> allowedRuleClasses = attribute.getAllowedRuleClassesPredicate();
+      if (allowedRuleClasses != Predicates.<RuleClass>alwaysFalse()) {
+        // See if there is an applicable rule among the already enqueued rules
+        BuildRuleBuilder referencedRuleBuilder = getFirstApplicableRule(allowedRuleClasses);
+        if (referencedRuleBuilder != null) {
+          label = ":" + referencedRuleBuilder.ruleName;
+        } else {
+          RuleClass referencedRuleClass = getFirstApplicableRuleClass(allowedRuleClasses);
+          if (referencedRuleClass != null) {
+            // Generate a rule with the appropriate ruleClass and a label for it in
+            // the original rule
+            label = ":" + getDummyRuleLabel(rulePkg, referencedRuleClass);
+          }
+        }
+      }
+    }
+    if (label != null) {
+      if (attrType == Type.LABEL_LIST || attrType == Type.OUTPUT_LIST) {
+        setMultiValueAttribute(attribute.getName(), label);
+      } else {
+        setSingleValueAttribute(attribute.getName(), label);
+      }
+    }
+    return this;
+  }
+
+  private BuildRuleBuilder getFirstApplicableRule(Predicate<RuleClass> allowedRuleClasses) {
+    // There is no direct way to get the set of allowedRuleClasses from the Attribute
+    // The Attribute API probably should not be modified for sole testing purposes
+    for (Map.Entry<String, BuildRuleBuilder> entry : generateRules.entrySet()) {
+      if (allowedRuleClasses.apply(ruleClassMap.get(entry.getKey()))) {
+        return entry.getValue();
+      }
+    }
+    return null;
+  }
+
+  private RuleClass getFirstApplicableRuleClass(Predicate<RuleClass> allowedRuleClasses) {
+    // See comments in getFirstApplicableRule(Predicate<RuleClass> allowedRuleClasses)
+    for (RuleClass ruleClass : ruleClassMap.values()) {
+      if (allowedRuleClasses.apply(ruleClass)) {
+        return ruleClass;
+      }
+    }
+    return null;
+  }
+
+  public BuildRuleWithDefaultsBuilder popuplateStringListAttribute(Attribute attribute) {
+    setMultiValueAttribute(attribute.getName(), "x");
+    return this;
+  }
+
+  public BuildRuleWithDefaultsBuilder popuplateStringAttribute(Attribute attribute) {
+    setSingleValueAttribute(attribute.getName(), "x");
+    return this;
+  }
+
+  public BuildRuleWithDefaultsBuilder popuplateBooleanAttribute(Attribute attribute) {
+    setSingleValueAttribute(attribute.getName(), "false");
+    return this;
+  }
+
+  public BuildRuleWithDefaultsBuilder popuplateIntegerAttribute(Attribute attribute) {
+    setSingleValueAttribute(attribute.getName(), 1);
+    return this;
+  }
+
+  public BuildRuleWithDefaultsBuilder popuplateAttributes(String rulePkg, boolean heuristics) {
+    for (Attribute attribute : ruleClass.getAttributes()) {
+      if (attribute.isMandatory()) {
+        if (attribute.getType() == Type.LABEL_LIST || attribute.getType() == Type.OUTPUT_LIST) {
+          if (attribute.isNonEmpty()) {
+            popuplateLabelAttribute(rulePkg, attribute);
+          } else {
+            // TODO(bazel-team): actually here an empty list would be fine, but BuildRuleBuilder
+            // doesn't support that, and it makes little sense anyway
+            popuplateLabelAttribute(rulePkg, attribute);
+          }
+        } else if (attribute.getType() == Type.LABEL || attribute.getType() == Type.OUTPUT) {
+          popuplateLabelAttribute(rulePkg, attribute);
+        } else {
+          // Non label type attributes
+          if (attribute.getAllowedValues() instanceof AllowedValueSet) {
+            Collection<Object> allowedValues =
+                ((AllowedValueSet) attribute.getAllowedValues()).getAllowedValues();
+            setSingleValueAttribute(attribute.getName(), allowedValues.iterator().next());
+          } else if (attribute.getType() == Type.STRING) {
+            popuplateStringAttribute(attribute);
+          } else if (attribute.getType() == Type.BOOLEAN) {
+            popuplateBooleanAttribute(attribute);
+          } else if (attribute.getType() == Type.INTEGER) {
+            popuplateIntegerAttribute(attribute);
+          } else if (attribute.getType() == Type.STRING_LIST) {
+            popuplateStringListAttribute(attribute);
+          }
+        }
+        // TODO(bazel-team): populate for other data types
+      } else if (heuristics) {
+        populateAttributesHeuristics(rulePkg, attribute);
+      }
+    }
+    return this;
+  }
+
+  // Heuristics which might help to generate valid rules.
+  // This is a bit hackish, but it helps some generated ruleclasses to pass analysis phase.
+  private void populateAttributesHeuristics(String rulePkg, Attribute attribute) {
+    if (attribute.getName().equals("srcs") && attribute.getType() == Type.LABEL_LIST) {
+      // If there is a srcs attribute it might be better to populate it even if it's not mandatory
+      popuplateLabelAttribute(rulePkg, attribute);
+    } else if (attribute.getName().equals("main_class") && attribute.getType() == Type.STRING) {
+      popuplateStringAttribute(attribute);
+    }
+  }
+
+  @Override
+  public Collection<String> getFilesToGenerate() {
+    return generateFiles;
+  }
+
+  @Override
+  public Collection<BuildRuleBuilder> getRulesToGenerate() {
+    return generateRules.values();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/ChattyAssertsTestCase.java b/src/test/java/com/google/devtools/build/lib/testutil/ChattyAssertsTestCase.java
new file mode 100644
index 0000000..f17d81e
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/ChattyAssertsTestCase.java
@@ -0,0 +1,237 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import com.google.common.collect.Sets;
+import com.google.devtools.build.lib.util.BlazeClock;
+import com.google.devtools.build.lib.util.ExitCode;
+
+import junit.framework.TestCase;
+
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Most of this stuff is copied from junit's {@link junit.framework.Assert}
+ * class, and then customized to make the error messages a bit more informative.
+ */
+public abstract class ChattyAssertsTestCase extends TestCase {
+  private long currentTestStartTime = -1;
+
+  @Override
+  protected void setUp() throws Exception {
+    super.setUp();
+    currentTestStartTime = BlazeClock.instance().currentTimeMillis();
+  }
+
+  @Override
+  protected void tearDown() throws Exception {
+    JunitTestUtils.nullifyInstanceFields(this);
+    assertFalse("tearDown without setUp!", currentTestStartTime == -1);
+
+    super.tearDown();
+  }
+
+  /**
+   * Asserts that two objects are equal. If they are not
+   * an AssertionFailedError is thrown with the given message.
+   */
+  public static void assertEquals(String message, Object expected,
+      Object actual) {
+    if (Objects.equals(expected, actual)) {
+      return;
+    }
+    chattyFailNotEquals(message, expected, actual);
+  }
+
+  /**
+   * Asserts that two objects are equal. If they are not
+   * an AssertionFailedError is thrown.
+   */
+  public static void assertEquals(Object expected, Object actual) {
+    assertEquals(null, expected, actual);
+  }
+
+  /**
+   * Asserts that two Strings are equal.
+   */
+  public static void assertEquals(String message, String expected, String actual) {
+    assertWithMessage(message).that(actual).isEqualTo(expected);
+  }
+
+  /**
+   * Asserts that two Strings are equal.
+   */
+  public static void assertEquals(String expected, String actual) {
+    assertEquals(null, expected, actual);
+  }
+
+  /**
+   * Asserts that two Strings are equal considering the line separator to be \n
+   * independently of the operating system.
+   */
+  public static void assertEqualsUnifyingLineEnds(String expected, String actual) {
+    MoreAsserts.assertEqualsUnifyingLineEnds(expected, actual);
+  }
+
+  private static void chattyFailNotEquals(String message, Object expected,
+      Object actual) {
+    fail(MoreAsserts.chattyFormat(message, expected, actual));
+  }
+
+  /**
+   * Asserts that {@code e}'s exception message contains each of {@code strings}
+   * <b>surrounded by single quotation marks</b>.
+   */
+  public static void assertMessageContainsWordsWithQuotes(Exception e,
+                                                          String... strings) {
+    assertContainsWordsWithQuotes(e.getMessage(), strings);
+  }
+
+  /**
+   * Asserts that {@code message} contains each of {@code strings}
+   * <b>surrounded by single quotation marks</b>.
+   */
+  public static void assertContainsWordsWithQuotes(String message,
+                                                   String... strings) {
+    MoreAsserts.assertContainsWordsWithQuotes(message, strings);
+  }
+
+  public static void assertNonZeroExitCode(int exitCode, String stdout, String stderr) {
+    MoreAsserts.assertNonZeroExitCode(exitCode, stdout, stderr);
+  }
+
+  public static void assertZeroExitCode(int exitCode, String stdout, String stderr) {
+    assertExitCode(0, exitCode, stdout, stderr);
+  }
+
+  public static void assertExitCode(ExitCode expectedExitCode,
+      int exitCode, String stdout, String stderr) {
+    int expectedExitCodeValue = expectedExitCode.getNumericExitCode();
+    if (exitCode != expectedExitCodeValue) {
+      fail(String.format("expected exit code '%s' <%d> but exit code was <%d> and stdout was <%s> "
+              + "and stderr was <%s>",
+              expectedExitCode.name(), expectedExitCodeValue, exitCode, stdout, stderr));
+    }
+  }
+
+  public static void assertExitCode(int expectedExitCode,
+      int exitCode, String stdout, String stderr) {
+    MoreAsserts.assertExitCode(expectedExitCode, exitCode,  stdout, stderr);
+  }
+
+  public static void assertStdoutContainsString(String expected, String stdout, String stderr) {
+    MoreAsserts.assertStdoutContainsString(expected, stdout, stderr);
+  }
+
+  public static void assertStderrContainsString(String expected, String stdout, String stderr) {
+    MoreAsserts.assertStderrContainsString(expected, stdout, stderr);
+  }
+
+  public static void assertStdoutContainsRegex(String expectedRegex,
+      String stdout, String stderr) {
+    MoreAsserts.assertStdoutContainsRegex(expectedRegex, stdout, stderr);
+  }
+
+  public static void assertStderrContainsRegex(String expectedRegex,
+      String stdout, String stderr) {
+    MoreAsserts.assertStderrContainsRegex(expectedRegex, stdout, stderr);
+  }
+
+
+
+  /********************************************************************
+   *                                                                  *
+   *       Other testing utilities (unrelated to "chattiness")        *
+   *                                                                  *
+   ********************************************************************/
+
+  /**
+   * Returns the elements from the given collection in a set.
+   */
+  protected static <T> Set<T> asSet(Iterable<T> collection) {
+    return Sets.newHashSet(collection);
+  }
+
+  /**
+   * Returns the arguments given as varargs as a set.
+   */
+  @SuppressWarnings({"unchecked", "varargs"})
+  protected static <T> Set<T> asSet(T... elements) {
+    return Sets.newHashSet(elements);
+  }
+
+  /**
+   * Returns the arguments given as varargs as a set of sorted Strings.
+   */
+  protected static Set<String> asStringSet(Iterable<?> collection) {
+    return MoreAsserts.asStringSet(collection);
+  }
+
+  /**
+   * An equivalence relation for Collection, based on mapping to Set.
+   *
+   * Oft-forgotten fact: for all x in Set, y in List, !x.equals(y) even if
+   * their elements are the same.
+   */
+  protected static <T> void
+      assertSameContents(Iterable<? extends T> expected, Iterable<? extends T> actual) {
+    MoreAsserts.assertSameContents(expected, actual);
+  }
+
+  /**
+   * Asserts the presence or absence of values in the collection.
+   */
+  protected <T> void assertPresence(Iterable<T> actual, Iterable<Presence<T>> expectedPresences) {
+    for (Presence<T> expected : expectedPresences) {
+      if (expected.presence) {
+        assertThat(actual).contains(expected.value);
+      } else {
+        assertThat(actual).doesNotContain(expected.value);
+      }
+    }
+  }
+
+  /** Creates a presence information with expected value. */
+  protected static <T> Presence<T> present(T expected) {
+    return new Presence<>(expected, true);
+  }
+
+  /** Creates an absence information with expected value. */
+  protected static <T> Presence<T> absent(T expected) {
+    return new Presence<>(expected, false);
+  }
+
+  /**
+   * Combines value with the boolean presence flag.
+   *
+   * @param <T> value type
+   */
+  protected final static class Presence <T> {
+    /** wrapped value */
+    public final T value;
+    /** boolean presence flag */
+    public final boolean presence;
+
+    /** Creates a tuple of value and a boolean presence flag. */
+    Presence(T value, boolean presence) {
+      this.value = value;
+      this.presence = presence;
+    }
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/Classpath.java b/src/test/java/com/google/devtools/build/lib/testutil/Classpath.java
new file mode 100644
index 0000000..94711ac
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/Classpath.java
@@ -0,0 +1,132 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import com.google.common.base.Preconditions;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Enumeration;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.regex.Pattern;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+/**
+ * A helper class to find all classes on the current classpath. This is used to automatically create
+ * JUnit 3 and 4 test suites.
+ */
+final class Classpath {
+  private static final String CLASS_EXTENSION = ".class";
+
+  /**
+   * Finds all classes that live in or below the given package.
+   */
+  static Set<Class<?>> findClasses(String packageName) {
+    Set<Class<?>> result = new LinkedHashSet<>();
+    String pathPrefix = (packageName + '.').replace('.', '/');
+    for (String entryName : getClassPath()) {
+      File classPathEntry = new File(entryName);
+      if (classPathEntry.exists()) {
+        try {
+          Set<String> classNames;
+          if (classPathEntry.isDirectory()) {
+            classNames = findClassesInDirectory(classPathEntry, pathPrefix);
+          } else {
+            classNames = findClassesInJar(classPathEntry, pathPrefix);
+          }
+          for (String className : classNames) {
+            Class<?> clazz = Class.forName(className);
+            result.add(clazz);
+          }
+        } catch (IOException e) {
+          throw new AssertionError("Can't read classpath entry "
+              + entryName + ": " + e.getMessage());
+        } catch (ClassNotFoundException e) {
+          throw new AssertionError("Class not found even though it is on the classpath "
+              + entryName + ": " + e.getMessage());
+        }
+      }
+    }
+    return result;
+  }
+
+  private static Set<String> findClassesInDirectory(File classPathEntry, String pathPrefix) {
+    Set<String> result = new TreeSet<>();
+    File directory = new File(classPathEntry, pathPrefix);
+    innerFindClassesInDirectory(result, directory, pathPrefix);
+    return result;
+  }
+
+  /**
+   * Finds all classes and sub packages in the given directory that are below the given package and
+   * add them to the respective sets.
+   *
+   * @param directory Directory to inspect
+   * @param pathPrefix Prefix for the path to the classes that are requested
+   *                   (ex: {@code com/google/foo/bar})
+   */
+  private static void innerFindClassesInDirectory(Set<String> classNames, File directory,
+      String pathPrefix) {
+    Preconditions.checkArgument(pathPrefix.endsWith("/"));
+    if (directory.exists()) {
+      for (File f : directory.listFiles()) {
+        String name = f.getName();
+        if (name.endsWith(CLASS_EXTENSION)) {
+          String clzName = getClassName(pathPrefix + name);
+          classNames.add(clzName);
+        } else if (f.isDirectory()) {
+          findClassesInDirectory(f, pathPrefix + name + "/");
+        }
+      }
+    }
+  }
+
+  /**
+   * Returns a set of all classes in the jar that start with the given prefix.
+   */
+  private static Set<String> findClassesInJar(File jarFile, String pathPrefix) throws IOException {
+    Set<String> classNames = new TreeSet<>();
+    try (ZipFile zipFile = new ZipFile(jarFile)) {
+      Enumeration<? extends ZipEntry> entries = zipFile.entries();
+      while (entries.hasMoreElements()) {
+        String entryName = entries.nextElement().getName();
+        if (entryName.startsWith(pathPrefix) && entryName.endsWith(CLASS_EXTENSION)) {
+          classNames.add(getClassName(entryName));
+        }
+      }
+    }
+    return classNames;
+  }
+
+  /**
+   * Given the absolute path of a class file, return the class name.
+   */
+  private static String getClassName(String className) {
+    int classNameEnd = className.length() - CLASS_EXTENSION.length();
+    return className.substring(0, classNameEnd).replace('/', '.');
+  }
+
+  /**
+   * Gets the class path from the System Property "java.class.path" and splits
+   * it up into the individual elements.
+   */
+  private static String[] getClassPath() {
+    String classPath = System.getProperty("java.class.path");
+    String separator = System.getProperty("path.separator", ":");
+    return classPath.split(Pattern.quote(separator));
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/ClasspathSuite.java b/src/test/java/com/google/devtools/build/lib/testutil/ClasspathSuite.java
new file mode 100644
index 0000000..ee880fc
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/ClasspathSuite.java
@@ -0,0 +1,43 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import org.junit.runners.Suite;
+import org.junit.runners.model.RunnerBuilder;
+
+import java.util.Set;
+
+/**
+ * A suite implementation that finds all JUnit 3 and 4 classes on the current classpath in or below
+ * the package of the annotated class, except classes that are annotated with {@code ClasspathSuite}
+ * or {@link CustomSuite}.
+ *
+ * <p>If you need to specify a custom test class filter or a different package prefix, then use
+ * {@link CustomSuite} instead.
+ */
+public final class ClasspathSuite extends Suite {
+
+  /**
+   * Only called reflectively. Do not use programmatically.
+   */
+  public ClasspathSuite(Class<?> klass, RunnerBuilder builder) throws Throwable {
+    super(builder, klass, getClasses(klass));
+  }
+
+  private static Class<?>[] getClasses(Class<?> klass) {
+    Set<Class<?>> result = new TestSuiteBuilder().addPackageRecursive(klass.getPackage().getName())
+        .create();
+    return result.toArray(new Class<?>[result.size()]);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/CustomSuite.java b/src/test/java/com/google/devtools/build/lib/testutil/CustomSuite.java
new file mode 100644
index 0000000..6e3b6c5
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/CustomSuite.java
@@ -0,0 +1,53 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import org.junit.runners.Suite;
+import org.junit.runners.model.RunnerBuilder;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.Set;
+
+/**
+ * A JUnit4 suite implementation that delegates the class finding to a {@code suite()} method on the
+ * annotated class. To be used in combination with {@link TestSuiteBuilder}.
+ */
+public final class CustomSuite extends Suite {
+
+  /**
+   * Only called reflectively. Do not use programmatically.
+   */
+  public CustomSuite(Class<?> klass, RunnerBuilder builder) throws Throwable {
+    super(builder, klass, getClasses(klass));
+  }
+
+  private static Class<?>[] getClasses(Class<?> klass) {
+    Set<Class<?>> result = evalSuite(klass);
+    return result.toArray(new Class<?>[result.size()]);
+  }
+
+  @SuppressWarnings("unchecked") // unchecked cast to a generic type
+  private static Set<Class<?>> evalSuite(Class<?> klass) {
+    try {
+      Method m = klass.getMethod("suite");
+      if (!Modifier.isStatic(m.getModifiers())) {
+        throw new IllegalStateException("suite() must be static");
+      }
+      return (Set<Class<?>>) m.invoke(null);
+    } catch (Exception e) {
+      throw new IllegalStateException(e);
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/DebuggingEventHandler.java b/src/test/java/com/google/devtools/build/lib/testutil/DebuggingEventHandler.java
new file mode 100644
index 0000000..59fe5fb
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/DebuggingEventHandler.java
@@ -0,0 +1,41 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import com.google.devtools.build.lib.events.Event;
+import com.google.devtools.build.lib.events.EventHandler;
+
+import java.io.PrintStream;
+
+/**
+ * Prints all errors and warnings to {@link System#out}.
+ */
+public class DebuggingEventHandler implements EventHandler {
+
+  private PrintStream out;
+
+  public DebuggingEventHandler() {
+    this.out = System.out;
+  }
+
+  @Override
+  public void handle(Event e) {
+    if (e.getLocation() != null) {
+      out.println(e.getKind() + " " + e.getLocation() + ": " + e.getMessage());
+    } else {
+      out.println(e.getKind() + " " + e.getMessage());
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/FoundationTestCase.java b/src/test/java/com/google/devtools/build/lib/testutil/FoundationTestCase.java
new file mode 100644
index 0000000..5c04612
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/FoundationTestCase.java
@@ -0,0 +1,264 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import com.google.common.io.Files;
+import com.google.devtools.build.lib.events.Event;
+import com.google.devtools.build.lib.events.EventCollector;
+import com.google.devtools.build.lib.events.EventHandler;
+import com.google.devtools.build.lib.events.EventKind;
+import com.google.devtools.build.lib.events.Reporter;
+import com.google.devtools.build.lib.util.BlazeClock;
+import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * This is a specialization of {@link ChattyAssertsTestCase} that's useful for
+ * implementing tests of the "foundation" library.
+ */
+public abstract class FoundationTestCase extends ChattyAssertsTestCase {
+
+  protected Path rootDirectory;
+
+  protected Path outputBase;
+
+  protected Path actionOutputBase;
+
+  // May be overridden by subclasses:
+  protected Reporter reporter;
+  protected EventCollector eventCollector;
+
+  private Scratch scratch;
+
+
+  // Individual tests can opt-out of this handler if they expect an error, by
+  // calling reporter.removeHandler(failFastHandler).
+  protected static final EventHandler failFastHandler = new EventHandler() {
+      @Override
+      public void handle(Event event) {
+        if (EventKind.ERRORS.contains(event.getKind())) {
+          fail(event.toString());
+        }
+      }
+    };
+
+  protected static final EventHandler printHandler = new EventHandler() {
+      @Override
+      public void handle(Event event) {
+        System.out.println(event);
+      }
+    };
+
+  @Override
+  protected void setUp() throws Exception {
+    super.setUp();
+    scratch = new Scratch(createFileSystem());
+    outputBase = scratchDir("/usr/local/google/_blaze_jrluser/FAKEMD5/");
+    rootDirectory = scratchDir("/" + TestConstants.TEST_WORKSPACE_DIRECTORY);
+    copySkylarkFilesIfExist();
+    actionOutputBase = scratchDir("/usr/local/google/_blaze_jrluser/FAKEMD5/action_out/");
+    eventCollector = new EventCollector(EventKind.ERRORS_AND_WARNINGS);
+    reporter = new Reporter(eventCollector);
+    reporter.addHandler(failFastHandler);
+  }
+
+  /*
+   * Creates the file system; override to inject FS behavior.
+   */
+  protected FileSystem createFileSystem() {
+     return new InMemoryFileSystem(BlazeClock.instance());
+  }
+
+
+  private void copySkylarkFilesIfExist() throws IOException {
+    scratchFile(rootDirectory.getRelative("devtools/blaze/rules/BUILD").getPathString());
+    scratchFile(rootDirectory.getRelative("rules/BUILD").getPathString());
+    copySkylarkFilesIfExist("devtools/blaze/rules/staging", "devtools/blaze/rules");
+    copySkylarkFilesIfExist("devtools/blaze/bazel/base_workspace/tools/build_rules", "rules");
+  }
+
+  private void copySkylarkFilesIfExist(String from, String to) throws IOException {
+    File rulesDir = new File(from);
+    if (rulesDir.exists() && rulesDir.isDirectory()) {
+      for (String fileName : rulesDir.list()) {
+        File file = new File(from + "/" + fileName);
+        if (file.isFile() && fileName.endsWith(".bzl")) {
+          String context = loadFile(file);
+          Path path = rootDirectory.getRelative(to + "/" + fileName);
+          if (path.exists()) {
+            overwriteScratchFile(path.getPathString(), context);
+          } else {
+            scratchFile(path.getPathString(), context);
+          }
+        }
+      }
+    }
+  }
+
+  @Override
+  protected void tearDown() throws Exception {
+    Thread.interrupted(); // Clear any interrupt pending against this thread,
+                          // so that we don't cause later tests to fail.
+
+    super.tearDown();
+  }
+
+  /**
+   * A scratch filesystem that is completely in-memory. Since this file system
+   * is "cached" in a private (but *not* static) field in the test class,
+   * each testFoo method in junit sees a fresh filesystem.
+   */
+  protected FileSystem scratchFS() {
+    return scratch.getFileSystem();
+  }
+
+  /**
+   * Create a scratch file in the scratch filesystem, with the given pathName,
+   * consisting of a set of lines. The method returns a Path instance for the
+   * scratch file.
+   */
+  protected Path scratchFile(String pathName, String... lines)
+      throws IOException {
+    return scratch.file(pathName, lines);
+  }
+
+  /**
+   * Like {@code scratchFile}, but the file is first deleted if it already
+   * exists.
+   */
+  protected Path overwriteScratchFile(String pathName, String... lines) throws IOException {
+    return scratch.overwriteFile(pathName, lines);
+  }
+
+  /**
+   * Deletes the specified scratch file, using the same specification as {@link Path#delete}.
+   */
+  protected boolean deleteScratchFile(String pathName) throws IOException {
+    return scratch.deleteFile(pathName);
+  }
+
+  /**
+   * Create a scratch file in the given filesystem, with the given pathName,
+   * consisting of a set of lines. The method returns a Path instance for the
+   * scratch file.
+   */
+  protected Path scratchFile(FileSystem fs, String pathName, String... lines)
+      throws IOException {
+    return scratch.file(fs, pathName, lines);
+  }
+
+  /**
+   * Create a scratch file in the given filesystem, with the given pathName,
+   * consisting of a set of lines. The method returns a Path instance for the
+   * scratch file.
+   */
+  protected Path scratchFile(FileSystem fs, String pathName, byte[] content)
+      throws IOException {
+    return scratch.file(fs, pathName, content);
+  }
+
+  /**
+   * Create a directory in the scratch filesystem, with the given path name.
+   */
+  public Path scratchDir(String pathName) throws IOException {
+    return scratch.dir(pathName);
+  }
+
+  /**
+   * If "expectedSuffix" is not a suffix of "actual", fails with an informative
+   * assertion.
+   */
+  protected void assertEndsWith(String expectedSuffix, String actual) {
+    if (!actual.endsWith(expectedSuffix)) {
+      fail("\"" + actual + "\" does not end with "
+           + "\"" + expectedSuffix + "\"");
+    }
+  }
+
+  /**
+   * If "expectedPrefix" is not a prefix of "actual", fails with an informative
+   * assertion.
+   */
+  protected void assertStartsWith(String expectedPrefix, String actual) {
+    if (!actual.startsWith(expectedPrefix)) {
+      fail("\"" + actual + "\" does not start with "
+           + "\"" + expectedPrefix + "\"");
+    }
+  }
+
+  // Mix-in assertions:
+
+  protected void assertNoEvents() {
+    JunitTestUtils.assertNoEvents(eventCollector);
+  }
+
+  protected Event assertContainsEvent(String expectedMessage) {
+    return JunitTestUtils.assertContainsEvent(eventCollector,
+                                              expectedMessage);
+  }
+
+  protected Event assertContainsEvent(String expectedMessage, Set<EventKind> kinds) {
+    return JunitTestUtils.assertContainsEvent(eventCollector,
+                                              expectedMessage,
+                                              kinds);
+  }
+
+  protected void assertContainsEventWithFrequency(String expectedMessage,
+      int expectedFrequency) {
+    JunitTestUtils.assertContainsEventWithFrequency(eventCollector, expectedMessage,
+        expectedFrequency);
+  }
+
+  protected void assertDoesNotContainEvent(String expectedMessage) {
+    JunitTestUtils.assertDoesNotContainEvent(eventCollector,
+                                             expectedMessage);
+  }
+
+  protected Event assertContainsEventWithWordsInQuotes(String... words) {
+    return JunitTestUtils.assertContainsEventWithWordsInQuotes(
+        eventCollector, words);
+  }
+
+  protected void assertContainsEventsInOrder(String... expectedMessages) {
+    JunitTestUtils.assertContainsEventsInOrder(eventCollector, expectedMessages);
+  }
+
+  @SuppressWarnings({"unchecked", "varargs"})
+  protected static <T> void assertContainsSublist(List<T> arguments,
+                                                  T... expectedSublist) {
+    JunitTestUtils.assertContainsSublist(arguments, expectedSublist);
+  }
+
+  @SuppressWarnings({"unchecked", "varargs"})
+  protected static <T> void assertDoesNotContainSublist(List<T> arguments,
+                                                        T... expectedSublist) {
+    JunitTestUtils.assertDoesNotContainSublist(arguments, expectedSublist);
+  }
+
+  protected static <T> void assertContainsSubset(Iterable<T> arguments,
+                                                 Iterable<T> expectedSubset) {
+    JunitTestUtils.assertContainsSubset(arguments, expectedSubset);
+  }
+
+  protected String loadFile(File file) throws IOException {
+    return Files.toString(file, Charset.defaultCharset());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/JunitTestUtils.java b/src/test/java/com/google/devtools/build/lib/testutil/JunitTestUtils.java
new file mode 100644
index 0000000..efe1599
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/JunitTestUtils.java
@@ -0,0 +1,310 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Sets;
+import com.google.devtools.build.lib.events.Event;
+import com.google.devtools.build.lib.events.EventCollector;
+import com.google.devtools.build.lib.events.EventKind;
+import com.google.devtools.build.lib.util.Pair;
+
+import junit.framework.TestCase;
+
+import java.lang.reflect.AccessibleObject;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * This class contains a utility method {@link #nullifyInstanceFields(Object)}
+ * for setting all fields in an instance to {@code null}. This is needed for
+ * junit {@code TestCase} instances that keep expensive objects in fields.
+ * Basically junit holds onto the instances
+ * even after the test methods have run, and it creates one such instance
+ * per {@code testFoo} method.
+ */
+public class JunitTestUtils {
+
+  public static void nullifyInstanceFields(Object instance)
+      throws IllegalAccessException {
+    /**
+     * We're cleaning up this test case instance by assigning null pointers
+     * to all fields to reduce the memory overhead of test case instances
+     * staying around after the test methods have been executed. This is a
+     * bug in junit.
+     */
+    List<Field> instanceFields = new ArrayList<>();
+    for (Class<?> clazz = instance.getClass();
+         !clazz.equals(TestCase.class) && !clazz.equals(Object.class);
+         clazz = clazz.getSuperclass()) {
+      for (Field field : clazz.getDeclaredFields()) {
+        if (Modifier.isStatic(field.getModifiers())) {
+          continue;
+        }
+        if (field.getType().isPrimitive()) {
+          continue;
+        }
+        if (Modifier.isFinal(field.getModifiers())) {
+          String msg = "Please make field \"" + field + "\" non-final, or, if " +
+                       "it's very simple and truly immutable and not too " +
+                       "big, make it static.";
+          throw new AssertionError(msg);
+        }
+        instanceFields.add(field);
+      }
+    }
+    // Run setAccessible for efficiency
+    AccessibleObject.setAccessible(instanceFields.toArray(new Field[0]), true);
+    for (Field field : instanceFields) {
+      field.set(instance, null);
+    }
+  }
+
+  /********************************************************************
+   *                                                                  *
+   *                         "Mix-in methods"                         *
+   *                                                                  *
+   ********************************************************************/
+
+  // Java doesn't support mix-ins, but we need them in our tests so that we can
+  // inherit a bunch of useful methods, e.g. assertions over an EventCollector.
+  // We do this by hand, by delegating from instance methods in each TestCase
+  // to the static methods below.
+
+  /**
+   * If the specified EventCollector contains any events, an informative
+   * assertion fails in the context of the specified TestCase.
+   */
+  public static void assertNoEvents(Iterable<Event> eventCollector) {
+    String eventsString = eventsToString(eventCollector);
+    assertThat(eventsString).isEmpty();
+  }
+
+  /**
+   * If the specified EventCollector contains an unexpected number of events, an informative
+   * assertion fails in the context of the specified TestCase.
+   */
+  public static void assertEventCount(int expectedCount, EventCollector eventCollector) {
+    assertWithMessage(eventsToString(eventCollector))
+        .that(eventCollector.count()).isEqualTo(expectedCount);
+  }
+
+  /**
+   * If the specified EventCollector does not contain an event which has
+   * 'expectedEvent' as a substring, an informative assertion fails. Otherwise
+   * the matching event is returned.
+   */
+  public static Event assertContainsEvent(Iterable<Event> eventCollector,
+      String expectedEvent) {
+    return assertContainsEvent(eventCollector, expectedEvent, EventKind.ALL_EVENTS);
+  }
+
+  /**
+   * If the specified EventCollector does not contain an event of a kind of 'kinds' which has
+   * 'expectedEvent' as a substring, an informative assertion fails. Otherwise
+   * the matching event is returned.
+   */
+  public static Event assertContainsEvent(Iterable<Event> eventCollector,
+                                          String expectedEvent,
+                                          Set<EventKind> kinds) {
+    for (Event event : eventCollector) {
+      if (event.getMessage().contains(expectedEvent) && kinds.contains(event.getKind())) {
+        return event;
+      }
+    }
+    String eventsString = eventsToString(eventCollector);
+    assertWithMessage("Event '" + expectedEvent + "' not found"
+        + (eventsString.length() == 0 ? "" : ("; found these though:" + eventsString)))
+        .that(false).isTrue();
+    return null; // unreachable
+  }
+
+  /**
+   * If the specified EventCollector contains an event which has
+   * 'expectedEvent' as a substring, an informative assertion fails.
+   */
+  public static void assertDoesNotContainEvent(Iterable<Event> eventCollector,
+                                          String expectedEvent) {
+    for (Event event : eventCollector) {
+      assertWithMessage("Unexpected string '" + expectedEvent + "' matched following event:\n"
+          + event.getMessage()).that(event.getMessage()).doesNotContain(expectedEvent);
+    }
+  }
+
+  /**
+   * If the specified EventCollector does not contain an event which has
+   * each of {@code words} surrounded by single quotes as a substring, an
+   * informative assertion fails.  Otherwise the matching event is returned.
+   */
+  public static Event assertContainsEventWithWordsInQuotes(
+      Iterable<Event> eventCollector,
+      String... words) {
+    for (Event event : eventCollector) {
+      boolean found = true;
+      for (String word : words) {
+        if (!event.getMessage().contains("'" + word + "'")) {
+          found = false;
+          break;
+        }
+      }
+      if (found) {
+        return event;
+      }
+    }
+    String eventsString = eventsToString(eventCollector);
+    assertWithMessage("Event containing words " + Arrays.toString(words) + " in "
+        + "single quotes not found"
+        + (eventsString.length() == 0 ? "" : ("; found these though:" + eventsString)))
+        .that(false).isTrue();
+    return null; // unreachable
+  }
+
+  /**
+   * Returns a string consisting of each event in the specified collector,
+   * preceded by a newline.
+   */
+  private static String eventsToString(Iterable<Event> eventCollector) {
+    StringBuilder buf = new StringBuilder();
+    eventLoop: for (Event event : eventCollector) {
+      for (String ignoredPrefix : TestConstants.IGNORED_MESSAGE_PREFIXES) {
+        if (event.getMessage().startsWith(ignoredPrefix)) {
+          continue eventLoop;
+        }
+      }
+      buf.append('\n').append(event);
+    }
+    return buf.toString();
+  }
+
+  /**
+   * If "expectedSublist" is not a sublist of "arguments", an informative
+   * assertion is failed in the context of the specified TestCase.
+   *
+   * Argument order mnemonic: assert(X)ContainsSublist(Y).
+   */
+  @SuppressWarnings({"unchecked", "varargs"})
+  public static <T> void assertContainsSublist(List<T> arguments, T... expectedSublist) {
+    List<T> sublist = Arrays.asList(expectedSublist);
+    try {
+      assertThat(Collections.indexOfSubList(arguments, sublist)).isNotEqualTo(-1);
+    } catch (AssertionError e) {
+      throw new AssertionError("Did not find " + sublist + " as a sublist of " + arguments, e);
+    }
+  }
+
+  /**
+   * If "expectedSublist" is a sublist of "arguments", an informative
+   * assertion is failed in the context of the specified TestCase.
+   *
+   * Argument order mnemonic: assert(X)DoesNotContainSublist(Y).
+   */
+  @SuppressWarnings({"unchecked", "varargs"})
+  public static <T> void assertDoesNotContainSublist(List<T> arguments, T... expectedSublist) {
+    List<T> sublist = Arrays.asList(expectedSublist);
+    try {
+      assertThat(Collections.indexOfSubList(arguments, sublist)).isEqualTo(-1);
+    } catch (AssertionError e) {
+      throw new AssertionError("Found " + sublist + " as a sublist of " + arguments, e);
+    }
+  }
+
+  /**
+   * If "arguments" does not contain "expectedSubset" as a subset, an
+   * informative assertion is failed in the context of the specified TestCase.
+   *
+   * Argument order mnemonic: assert(X)ContainsSubset(Y).
+   */
+  public static <T> void assertContainsSubset(Iterable<T> arguments,
+                                              Iterable<T> expectedSubset) {
+    Set<T> argumentsSet = arguments instanceof Set<?>
+        ? (Set<T>) arguments
+        : Sets.newHashSet(arguments);
+
+    for (T x : expectedSubset) {
+      assertWithMessage("assertContainsSubset failed: did not find element " + x
+          + "\nExpected subset = " + expectedSubset + "\nArguments = " + arguments)
+          .that(argumentsSet).contains(x);
+    }
+  }
+
+  /**
+   * Check to see if each element of expectedMessages is the beginning of a message
+   * in eventCollector, in order, as in {@link #containsSublistWithGapsAndEqualityChecker}.
+   * If not, an informative assertion is failed
+   */
+  protected static void assertContainsEventsInOrder(Iterable<Event> eventCollector,
+      String... expectedMessages) {
+    String failure = containsSublistWithGapsAndEqualityChecker(
+        ImmutableList.copyOf(eventCollector),
+        new Function<Pair<Event, String>, Boolean> () {
+      @Override
+      public Boolean apply(Pair<Event, String> pair) {
+        return pair.first.getMessage().contains(pair.second);
+      }
+    }, expectedMessages);
+
+    String eventsString = eventsToString(eventCollector);
+    assertWithMessage("Event '" + failure + "' not found in proper order"
+        + (eventsString.length() == 0 ? "" : ("; found these though:" + eventsString)))
+        .that(failure).isNull();
+  }
+
+  /**
+   * Check to see if each element of expectedSublist is in arguments, according to
+   * the equalityChecker, in the same order as in expectedSublist (although with
+   * other interspersed elements in arguments allowed).
+   * @param equalityChecker function that takes a Pair<S, T> element and returns true
+   * if the elements of the pair are equal by its lights.
+   * @return first element not in arguments in order, or null if success.
+   */
+  @SuppressWarnings({"unchecked"})
+  protected static <S, T> T containsSublistWithGapsAndEqualityChecker(List<S> arguments,
+      Function<Pair<S, T>, Boolean> equalityChecker, T... expectedSublist) {
+    Iterator<S> iter = arguments.iterator();
+    outerLoop:
+    for (T expected : expectedSublist) {
+      while (iter.hasNext()) {
+        S actual = iter.next();
+        if (equalityChecker.apply(Pair.of(actual, expected))) {
+          continue outerLoop;
+        }
+      }
+      return expected;
+    }
+    return null;
+  }
+
+  public static List<Event> assertContainsEventWithFrequency(Iterable<Event> events,
+      String expectedMessage, int expectedFrequency) {
+    ImmutableList.Builder<Event> builder = ImmutableList.builder();
+    for (Event event : events) {
+      if (event.getMessage().contains(expectedMessage)) {
+        builder.add(event);
+      }
+    }
+    List<Event> foundEvents = builder.build();
+    assertWithMessage(foundEvents.toString()).that(foundEvents).hasSize(expectedFrequency);
+    return foundEvents;
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/ManualClock.java b/src/test/java/com/google/devtools/build/lib/testutil/ManualClock.java
new file mode 100644
index 0000000..d4f6058
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/ManualClock.java
@@ -0,0 +1,40 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import com.google.devtools.build.lib.util.Clock;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A fake clock for testing.
+ */
+public final class ManualClock implements Clock {
+  private long currentTimeMillis = 0L;
+
+  @Override
+  public long currentTimeMillis() {
+    return currentTimeMillis;
+  }
+
+  @Override
+  public long nanoTime() {
+    return TimeUnit.MILLISECONDS.toNanos(currentTimeMillis);
+  }
+
+  public void advanceMillis(long time) {
+    currentTimeMillis += time;
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/MoreAsserts.java b/src/test/java/com/google/devtools/build/lib/testutil/MoreAsserts.java
new file mode 100644
index 0000000..9224b8a
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/MoreAsserts.java
@@ -0,0 +1,319 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth.assert_;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Predicate;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+
+import java.lang.ref.Reference;
+import java.lang.reflect.Field;
+import java.util.Comparator;
+import java.util.Map;
+import java.util.Queue;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * A helper class for tests providing a simple interface for asserts.
+ */
+public class MoreAsserts {
+
+  public static void assertContainsRegex(String regex, String actual) {
+    assertThat(actual).containsMatch(regex);
+  }
+
+  public static void assertContainsRegex(String msg, String regex, String actual) {
+    assertWithMessage(msg).that(actual).containsMatch(regex);
+  }
+
+  public static void assertNotContainsRegex(String regex, String actual) {
+    assertThat(actual).doesNotContainMatch(regex);
+  }
+
+  public static void assertNotContainsRegex(String msg, String regex, String actual) {
+    assertWithMessage(msg).that(actual).doesNotContainMatch(regex);
+  }
+
+  public static void assertMatchesRegex(String regex, String actual) {
+    assertThat(actual).matches(regex);
+  }
+
+  public static void assertMatchesRegex(String msg, String regex, String actual) {
+    assertWithMessage(msg).that(actual).matches(regex);
+  }
+
+  public static void assertNotMatchesRegex(String regex, String actual) {
+    assertThat(actual).doesNotMatch(regex);
+  }
+
+  public static <T> void assertEquals(T expected, T actual, Comparator<T> comp) {
+    assertThat(comp.compare(expected, actual)).isEqualTo(0);
+  }
+
+  public static <T> void assertContentsAnyOrder(
+      Iterable<? extends T> expected, Iterable<? extends T> actual,
+      Comparator<? super T> comp) {
+    assertThat(actual).hasSize(Iterables.size(expected));
+    int i = 0;
+    for (T e : expected) {
+      for (T a : actual) {
+        if (comp.compare(e, a) == 0) {
+          i++;
+        }
+      }
+    }
+    assertThat(actual).hasSize(i);
+  }
+
+  public static void assertGreaterThanOrEqual(long target, long actual) {
+    assertThat(actual).isAtLeast(target);
+  }
+
+  public static void assertGreaterThanOrEqual(String msg, long target, long actual) {
+    assertWithMessage(msg).that(actual).isAtLeast(target);
+  }
+
+  public static void assertGreaterThan(long target, long actual) {
+    assertThat(actual).isGreaterThan(target);
+  }
+
+  public static void assertGreaterThan(String msg, long target, long actual) {
+    assertWithMessage(msg).that(actual).isGreaterThan(target);
+  }
+
+  public static void assertLessThanOrEqual(long target, long actual) {
+    assertThat(actual).isAtMost(target);
+  }
+
+  public static void assertLessThanOrEqual(String msg, long target, long actual) {
+    assertWithMessage(msg).that(actual).isAtMost(target);
+  }
+
+  public static void assertLessThan(long target, long actual) {
+    assertThat(actual).isLessThan(target);
+  }
+
+  public static void assertLessThan(String msg, long target, long actual) {
+    assertWithMessage(msg).that(actual).isLessThan(target);
+  }
+
+  public static void assertEndsWith(String ending, String actual) {
+    assertThat(actual).endsWith(ending);
+  }
+
+  public static void assertStartsWith(String prefix, String actual) {
+    assertThat(actual).startsWith(prefix);
+  }
+
+  /**
+   * Scans if an instance of given class is strongly reachable from a given
+   * object.
+   * <p>Runs breadth-first search in object reachability graph to check if
+   * an instance of <code>clz</code> can be reached.
+   * <strong>Note:</strong> This method can take a long time if analyzed
+   * data structure spans across large part of heap and may need a lot of
+   * memory.
+   *
+   * @param start object to start the search from
+   * @param clazz class to look for
+   */
+  public static void assertInstanceOfNotReachable(
+      Object start, final Class<?> clazz) {
+    Predicate<Object> p = new Predicate<Object>() {
+      @Override
+      public boolean apply(Object obj) {
+        return clazz.isAssignableFrom(obj.getClass());
+      }
+    };
+    if (isRetained(p, start)) {
+      assert_().fail("Found an instance of " + clazz.getCanonicalName() +
+          " reachable from " + start.toString());
+    }
+  }
+
+  private static final Field NON_STRONG_REF;
+
+  static {
+    try {
+      NON_STRONG_REF = Reference.class.getDeclaredField("referent");
+    } catch (SecurityException e) {
+      throw new RuntimeException(e);
+    } catch (NoSuchFieldException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  static final Predicate<Field> ALL_STRONG_REFS = new Predicate<Field>() {
+    @Override
+    public boolean apply(Field field) {
+      return NON_STRONG_REF.equals(field);
+    }
+  };
+
+  private static boolean isRetained(Predicate<Object> predicate, Object start) {
+    Map<Object, Object> visited = Maps.newIdentityHashMap();
+    visited.put(start, start);
+    Queue<Object> toScan = Lists.newLinkedList();
+    toScan.add(start);
+
+    while (!toScan.isEmpty()) {
+      Object current = toScan.poll();
+      if (current.getClass().isArray()) {
+        if (current.getClass().getComponentType().isPrimitive()) {
+          continue;
+        }
+
+        for (Object ref : (Object[]) current) {
+          if (ref != null) {
+            if (predicate.apply(ref)) {
+              return true;
+            }
+            if (visited.put(ref, ref) == null) {
+              toScan.add(ref);
+            }
+          }
+        }
+      } else {
+        // iterate *all* fields (getFields() returns only accessible ones)
+        for (Class<?> clazz = current.getClass(); clazz != null;
+            clazz = clazz.getSuperclass()) {
+          for (Field f : clazz.getDeclaredFields()) {
+            if (f.getType().isPrimitive() || ALL_STRONG_REFS.apply(f)) {
+              continue;
+            }
+
+            f.setAccessible(true);
+            try {
+              Object ref = f.get(current);
+              if (ref != null) {
+                if (predicate.apply(ref)) {
+                  return true;
+                }
+                if (visited.put(ref, ref) == null) {
+                  toScan.add(ref);
+                }
+              }
+            } catch (IllegalArgumentException e) {
+              throw new IllegalStateException("Error when scanning the heap", e);
+            } catch (IllegalAccessException e) {
+              throw new IllegalStateException("Error when scanning the heap", e);
+            }
+          }
+        }
+      }
+    }
+    return false;
+  }
+
+  private static String getClassDescription(Object object) {
+    return object == null
+        ? "null"
+        : ("instance of " + object.getClass().getName());
+  }
+
+  public static String chattyFormat(String message, Object expected, Object actual) {
+    String expectedClass = getClassDescription(expected);
+    String actualClass = getClassDescription(actual);
+
+    return Joiner.on('\n').join((message != null) ? ("\n" + message) : "",
+        "  expected " + expectedClass + ": <" + expected + ">",
+        "  but was " + actualClass + ": <" + actual + ">");
+  }
+
+  public static void assertEqualsUnifyingLineEnds(String expected, String actual) {
+    if (actual != null) {
+      actual = actual.replaceAll(System.getProperty("line.separator"), "\n");
+    }
+    assertThat(actual).isEqualTo(expected);
+  }
+
+  public static void assertContainsWordsWithQuotes(String message,
+      String... strings) {
+    for (String string : strings) {
+      assertTrue(message + " should contain '" + string + "' (with quotes)",
+          message.contains("'" + string + "'"));
+    }
+  }
+
+  public static void assertNonZeroExitCode(int exitCode, String stdout, String stderr) {
+    if (exitCode == 0) {
+      fail("expected non-zero exit code but exit code was 0 and stdout was <"
+          + stdout + "> and stderr was <" + stderr + ">");
+    }
+  }
+
+  public static void assertExitCode(int expectedExitCode,
+      int exitCode, String stdout, String stderr) {
+    if (exitCode != expectedExitCode) {
+      fail(String.format("expected exit code <%d> but exit code was <%d> and stdout was <%s> "
+          + "and stderr was <%s>", expectedExitCode, exitCode, stdout, stderr));
+    }
+  }
+
+  public static void assertStdoutContainsString(String expected, String stdout, String stderr) {
+    if (!stdout.contains(expected)) {
+      fail("expected stdout to contain string <" + expected + "> but stdout was <"
+          + stdout + "> and stderr was <" + stderr + ">");
+    }
+  }
+
+  public static void assertStderrContainsString(String expected, String stdout, String stderr) {
+    if (!stderr.contains(expected)) {
+      fail("expected stderr to contain string <" + expected + "> but stdout was <"
+          + stdout + "> and stderr was <" + stderr + ">");
+    }
+  }
+
+  public static void assertStdoutContainsRegex(String expectedRegex,
+      String stdout, String stderr) {
+    if (!Pattern.compile(expectedRegex).matcher(stdout).find()) {
+      fail("expected stdout to contain regex <" + expectedRegex + "> but stdout was <"
+          + stdout + "> and stderr was <" + stderr + ">");
+    }
+  }
+
+  public static void assertStderrContainsRegex(String expectedRegex,
+      String stdout, String stderr) {
+    if (!Pattern.compile(expectedRegex).matcher(stderr).find()) {
+      fail("expected stderr to contain regex <" + expectedRegex + "> but stdout was <"
+          + stdout + "> and stderr was <" + stderr + ">");
+    }
+  }
+
+  public static Set<String> asStringSet(Iterable<?> collection) {
+    Set<String> set = Sets.newTreeSet();
+    for (Object o : collection) {
+      set.add("\"" + String.valueOf(o) + "\"");
+    }
+    return set;
+  }
+
+  public static <T> void
+  assertSameContents(Iterable<? extends T> expected, Iterable<? extends T> actual) {
+    if (!Sets.newHashSet(expected).equals(Sets.newHashSet(actual))) {
+      fail("got string set: " + asStringSet(actual).toString()
+          + "\nwant: " + asStringSet(expected).toString());
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/Scratch.java b/src/test/java/com/google/devtools/build/lib/testutil/Scratch.java
new file mode 100644
index 0000000..229d2a7
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/Scratch.java
@@ -0,0 +1,150 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import com.google.devtools.build.lib.util.BlazeClock;
+import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
+
+import java.io.IOException;
+
+/**
+ * Allow tests to easily manage scratch files in a FileSystem.
+ */
+public final class Scratch {
+
+  private final FileSystem fileSystem;
+
+  /**
+   * Create a new ScratchFileSystem using the {@link InMemoryFileSystem}
+   */
+  public Scratch() {
+    this(new InMemoryFileSystem(BlazeClock.instance()));
+  }
+
+  /**
+   * Create a new ScratchFileSystem using the supplied FileSystem.
+   */
+  public Scratch(FileSystem fileSystem) {
+    this.fileSystem = fileSystem;
+  }
+
+  /**
+   * Returns the FileSystem in use.
+   */
+  public FileSystem getFileSystem() {
+    return fileSystem;
+  }
+
+  /**
+   * Create a directory in the scratch filesystem, with the given path name.
+   */
+  public Path dir(String pathName) throws IOException {
+    Path dir = getFileSystem().getPath(pathName);
+    if (!dir.exists()) {
+      FileSystemUtils.createDirectoryAndParents(dir);
+    }
+    if (!dir.isDirectory()) {
+      throw new IOException("Exists, but is not a directory: " + pathName);
+    }
+    return dir;
+  }
+
+  /**
+   * Create a scratch file in the scratch filesystem, with the given pathName,
+   * consisting of a set of lines. The method returns a Path instance for the
+   * scratch file.
+   */
+  public Path file(String pathName, String... lines)
+      throws IOException {
+    Path newFile = file(getFileSystem(), pathName, lines);
+    newFile.setLastModifiedTime(-1L);
+    return newFile;
+  }
+
+  /**
+   * Like {@code scratchFile}, but the file is first deleted if it already
+   * exists.
+   */
+  public Path overwriteFile(String pathName, String... lines) throws IOException {
+    Path oldFile = getFileSystem().getPath(pathName);
+    long newMTime = oldFile.exists() ? oldFile.getLastModifiedTime() + 1 : -1;
+    oldFile.delete();
+    Path newFile = file(getFileSystem(), pathName, lines);
+    newFile.setLastModifiedTime(newMTime);
+    return newFile;
+  }
+
+  /**
+   * Deletes the specified scratch file, using the same specification as {@link Path#delete}.
+   */
+  public boolean deleteFile(String pathName) throws IOException {
+    Path file = getFileSystem().getPath(pathName);
+    return file.delete();
+  }
+
+  /**
+   * Create a scratch file in the given filesystem, with the given pathName,
+   * consisting of a set of lines. The method returns a Path instance for the
+   * scratch file.
+   */
+  public Path file(FileSystem fs, String pathName, String... lines)
+      throws IOException {
+    Path file = newScratchFile(fs, pathName);
+    FileSystemUtils.writeContentAsLatin1(file, linesAsString(lines));
+    return file;
+  }
+
+  /**
+   * Create a scratch file in the given filesystem, with the given pathName,
+   * consisting of a set of lines. The method returns a Path instance for the
+   * scratch file.
+   */
+  public Path file(FileSystem fs, String pathName, byte[] content)
+      throws IOException {
+    Path file = newScratchFile(fs, pathName);
+    FileSystemUtils.writeContent(file, content);
+    return file;
+  }
+
+  /** Creates a new scratch file, ensuring parents exist. */
+  private Path newScratchFile(FileSystem fs, String pathName) throws IOException {
+    Path file = fs.getPath(pathName);
+    Path parentDir = file.getParentDirectory();
+    if (!parentDir.exists()) {
+      FileSystemUtils.createDirectoryAndParents(parentDir);
+    }
+    if (file.exists()) {
+      throw new IOException("Could not create scratch file (file exists) "
+          + pathName);
+    }
+    return file;
+  }
+
+  /**
+   * Converts the lines into a String with linebreaks. Useful for creating
+   * in-memory input for a file, for example.
+   */
+  private static String linesAsString(String... lines) {
+    StringBuilder builder = new StringBuilder();
+    for (String line : lines) {
+      builder.append(line);
+      builder.append('\n');
+    }
+    return builder.toString();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/Suite.java b/src/test/java/com/google/devtools/build/lib/testutil/Suite.java
new file mode 100644
index 0000000..43590d4
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/Suite.java
@@ -0,0 +1,86 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+/**
+ * Test annotations used to select which tests to run in a given situation.
+ */
+public enum Suite {
+
+  /**
+   * It's so blazingly fast and lightweight we run it whenever we make any
+   * build.lib change. This size is the default.
+   */
+  SMALL_TESTS,
+
+  /**
+   * It's a bit too slow to run all the time, but it still tests some
+   * unit of functionality. May run external commands such as gcc, for example.
+   */
+  MEDIUM_TESTS,
+
+  /**
+   * I don't even want to think about running this one after every edit,
+   * but I don't mind if the continuous build runs it, and I'm happy to have
+   * it before making a release.
+   */
+  LARGE_TESTS,
+
+  /**
+   * These tests take a long time. They should only ever be run manually and probably from their
+   * own Blaze test target.
+   */
+  ENORMOUS_TESTS;
+
+  /**
+   * Given a class, determine the test size.
+   */
+  public static Suite getSize(Class<?> clazz) {
+    return getAnnotationElementOrDefault(clazz, "size");
+  }
+
+  /**
+   * Given a class, determine the suite it belongs to.
+   */
+  public static String getSuiteName(Class<?> clazz) {
+    return getAnnotationElementOrDefault(clazz, "suite");
+  }
+
+  /**
+   * Given a class, determine if it is flaky.
+   */
+  public static boolean isFlaky(Class<?> clazz) {
+    return getAnnotationElementOrDefault(clazz, "flaky");
+  }
+
+  /**
+   * Returns the value of the given element in the {@link TestSpec} annotation of the given class,
+   * or the default value of that element if the class doesn't have a {@link TestSpec} annotation.
+   */
+  @SuppressWarnings("unchecked")
+  private static <T> T getAnnotationElementOrDefault(Class<?> clazz, String elementName) {
+    TestSpec spec = clazz.getAnnotation(TestSpec.class);
+    try {
+      Method method = TestSpec.class.getMethod(elementName);
+      return spec != null ? (T) method.invoke(spec) : (T) method.getDefaultValue();
+    } catch (NoSuchMethodException e) {
+      throw new IllegalStateException("no such element " + elementName, e);
+    } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
+      throw new IllegalStateException("can't invoke accessor for element " + elementName, e);
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/TestConstants.java b/src/test/java/com/google/devtools/build/lib/testutil/TestConstants.java
new file mode 100644
index 0000000..d9552ad
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/TestConstants.java
@@ -0,0 +1,53 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import com.google.common.collect.ImmutableList;
+
+/**
+ * Various constants required by the tests.
+ */
+public class TestConstants {
+  private TestConstants() {
+  }
+
+  /**
+   * A list of all embedded binaries that go into the regular Bazel binary.
+   */
+  public static final ImmutableList<String> EMBEDDED_TOOLS = ImmutableList.of(
+      "build-runfiles",
+      "process-wrapper",
+      "build_interface_so");
+
+
+  /**
+   * Location in the bazel repo where embedded binaries come from.
+   */
+  public static final String EMBEDDED_SCRIPTS_PATH = "DOES-NOT-WORK-YET";
+
+  /**
+   * Directory where we can find bazel's Java tests, relative to a test's runfiles directory.
+   */
+  public static final String JAVATESTS_ROOT = "src/test/java/";
+
+  /**
+   * The directory in InMemoryFileSystem where workspaces created during unit tests reside.
+   */
+  public static final String TEST_WORKSPACE_DIRECTORY = "bazel";
+
+  public static final String TEST_RULE_CLASS_PROVIDER =
+      "com.google.devtools.build.lib.bazel.rules.BazelRuleClassProvider";
+  public static final ImmutableList<String> IGNORED_MESSAGE_PREFIXES = ImmutableList.<String>of();
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/TestFileOutErr.java b/src/test/java/com/google/devtools/build/lib/testutil/TestFileOutErr.java
new file mode 100644
index 0000000..6f0494f
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/TestFileOutErr.java
@@ -0,0 +1,127 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import com.google.devtools.build.lib.util.io.FileOutErr;
+import com.google.devtools.build.lib.util.io.RecordingOutErr;
+import com.google.devtools.build.lib.vfs.Path;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * An implementation of the FileOutErr that doesn't use a file.
+ * This is useful for tests, as they often test the action directly
+ * and would otherwise have to create files on the vfs.
+ */
+public class TestFileOutErr extends FileOutErr {
+
+  RecordingOutErr recorder;
+
+  public TestFileOutErr(TestFileOutErr arg) {
+    this(arg.getOutputStream(), arg.getErrorStream());
+  }
+
+  public TestFileOutErr() {
+    this(new ByteArrayOutputStream(), new ByteArrayOutputStream());
+  }
+
+  public TestFileOutErr(ByteArrayOutputStream stream) {
+    super(null, null); // This is a pretty brutal overloading - We're just inheriting for the type.
+    recorder = new RecordingOutErr(stream, stream);
+  }
+
+  public TestFileOutErr(ByteArrayOutputStream stream1, ByteArrayOutputStream stream2) {
+    super(null, null); // This is a pretty brutal overloading - We're just inheriting for the type.
+    recorder = new RecordingOutErr(stream1, stream2);
+  }
+
+
+  @Override
+  public Path getOutputFile() {
+    return null;
+  }
+
+  @Override
+  public Path getErrorFile() {
+    return null;
+  }
+
+  @Override
+  public ByteArrayOutputStream getOutputStream() {
+    return recorder.getOutputStream();
+  }
+
+  @Override
+  public ByteArrayOutputStream getErrorStream() {
+    return recorder.getErrorStream();
+  }
+
+  @Override
+  public void printOut(String s) {
+    recorder.printOut(s);
+  }
+
+  @Override
+  public void printErr(String s) {
+    recorder.printErr(s);
+  }
+
+  @Override
+  public String toString() {
+    return recorder.toString();
+  }
+
+  @Override
+  public boolean hasRecordedOutput() {
+    return recorder.hasRecordedOutput();
+  }
+
+  @Override
+  public String outAsLatin1() {
+    return recorder.outAsLatin1();
+  }
+
+  @Override
+  public String errAsLatin1() {
+    return recorder.errAsLatin1();
+  }
+
+  @Override
+  public void dumpOutAsLatin1(OutputStream out) {
+    try {
+      out.write(recorder.getOutputStream().toByteArray());
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  @Override
+  public void dumpErrAsLatin1(OutputStream out) {
+    try {
+      out.write(recorder.getErrorStream().toByteArray());
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  public String getRecordedOutput() {
+    return recorder.outAsLatin1() + recorder.errAsLatin1();
+  }
+
+  public void reset() {
+    recorder.reset();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/TestRuleClassProvider.java b/src/test/java/com/google/devtools/build/lib/testutil/TestRuleClassProvider.java
new file mode 100644
index 0000000..752605c
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/TestRuleClassProvider.java
@@ -0,0 +1,84 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import static com.google.devtools.build.lib.packages.Attribute.attr;
+import static com.google.devtools.build.lib.packages.Type.INTEGER;
+import static com.google.devtools.build.lib.packages.Type.LABEL_LIST;
+import static com.google.devtools.build.lib.packages.Type.OUTPUT_LIST;
+import static com.google.devtools.build.lib.packages.Type.STRING_LIST;
+
+import com.google.devtools.build.lib.analysis.BaseRuleClasses;
+import com.google.devtools.build.lib.analysis.BlazeRule;
+import com.google.devtools.build.lib.analysis.ConfiguredRuleClassProvider;
+import com.google.devtools.build.lib.analysis.RuleDefinition;
+import com.google.devtools.build.lib.analysis.RuleDefinitionEnvironment;
+import com.google.devtools.build.lib.packages.RuleClass;
+import com.google.devtools.build.lib.packages.RuleClass.Builder;
+import com.google.devtools.build.lib.util.FileTypeSet;
+
+import java.lang.reflect.Method;
+
+/**
+ * Helper class to provide a RuleClassProvider for tests.
+ */
+public class TestRuleClassProvider {
+  private static ConfiguredRuleClassProvider ruleProvider = null;
+
+  /**
+   * Adds all the rule classes supported internally within the build tool to the given builder.
+   */
+  public static void addStandardRules(ConfiguredRuleClassProvider.Builder builder) {
+    try {
+      Class<?> providerClass = Class.forName(TestConstants.TEST_RULE_CLASS_PROVIDER);
+      Method setupMethod = providerClass.getMethod("setup",
+          ConfiguredRuleClassProvider.Builder.class);
+      setupMethod.invoke(null, builder);
+    } catch (Exception e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  /**
+   * Return a rule class provider.
+   */
+  public static ConfiguredRuleClassProvider getRuleClassProvider() {
+    if (ruleProvider == null) {
+      ConfiguredRuleClassProvider.Builder builder =
+          new ConfiguredRuleClassProvider.Builder();
+      addStandardRules(builder);
+      builder.addRuleDefinition(TestingDummyRule.class);
+      ruleProvider = builder.build();
+    }
+    return ruleProvider;
+  }
+
+  @BlazeRule(name = "testing_dummy_rule",
+               ancestors = { BaseRuleClasses.RuleBase.class },
+               // Instantiated only in tests
+               factoryClass = UnknownRuleConfiguredTarget.class)
+  public static final class TestingDummyRule implements RuleDefinition {
+    @Override
+    public RuleClass build(Builder builder, RuleDefinitionEnvironment env) {
+      return builder
+          .setUndocumented()
+          .add(attr("srcs", LABEL_LIST).allowedFileTypes(FileTypeSet.ANY_FILE))
+          .add(attr("outs", OUTPUT_LIST))
+          .add(attr("dummystrings", STRING_LIST))
+          .add(attr("dummyinteger", INTEGER))
+          .build();
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/TestSpec.java b/src/test/java/com/google/devtools/build/lib/testutil/TestSpec.java
new file mode 100644
index 0000000..316169c
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/TestSpec.java
@@ -0,0 +1,48 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An annotation class which we use to attach a little meta data to test
+ * classes. For now, we use this to attach a {@link Suite}.
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Inherited
+public @interface TestSpec {
+
+  /**
+   * The size of the specified test, in terms of its resource consumption and
+   * execution time.
+   */
+  Suite size() default Suite.SMALL_TESTS;
+
+  /**
+   * The name of the suite to which this test belongs.  Useful for creating
+   * test suites organised by function.
+   */
+  String suite() default "";
+
+  /**
+   * If the test will pass consistently without outside changes.
+   * This should be fixed as soon as possible.
+   */
+  boolean flaky() default false;
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/TestSuiteBuilder.java b/src/test/java/com/google/devtools/build/lib/testutil/TestSuiteBuilder.java
new file mode 100644
index 0000000..af90c52
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/TestSuiteBuilder.java
@@ -0,0 +1,139 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import com.google.common.base.Predicate;
+import com.google.common.base.Predicates;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+
+import junit.framework.TestCase;
+
+import org.junit.runner.RunWith;
+
+import java.lang.reflect.Modifier;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * A collector for test classes, for both JUnit 3 and 4. To be used in combination with {@link
+ * CustomSuite}.
+ */
+public final class TestSuiteBuilder {
+
+  private Set<Class<?>> testClasses = Sets.newTreeSet(new TestClassNameComparator());
+  private Predicate<Class<?>> matchClassPredicate = Predicates.alwaysTrue();
+
+  /**
+   * Adds the tests found (directly) in class {@code c} to the set of tests
+   * this builder will search.
+   */
+  public TestSuiteBuilder addTestClass(Class<?> c) {
+    testClasses.add(c);
+    return this;
+  }
+
+  /**
+   * Adds all the test classes (top-level or nested) found in package
+   * {@code pkgName} or its subpackages to the set of tests this builder will
+   * search.
+   */
+  public TestSuiteBuilder addPackageRecursive(String pkgName) {
+    for (Class<?> c : getClassesRecursive(pkgName)) {
+      addTestClass(c);
+    }
+    return this;
+  }
+
+  private Set<Class<?>> getClassesRecursive(String pkgName) {
+    Set<Class<?>> result = new LinkedHashSet<>();
+    for (Class<?> clazz : Classpath.findClasses(pkgName)) {
+      if (isTestClass(clazz)) {
+        result.add(clazz);
+      }
+    }
+    return result;
+  }
+
+  /**
+   * Specifies a predicate returns false for classes we want to exclude.
+   */
+  public TestSuiteBuilder matchClasses(Predicate<Class<?>> predicate) {
+    matchClassPredicate = predicate;
+    return this;
+  }
+
+  /**
+   * Creates and returns a TestSuite containing the tests from the given
+   * classes and/or packages which matched the given tags.
+   */
+  public Set<Class<?>> create() {
+    Set<Class<?>> result = new LinkedHashSet<>();
+    // We have some cases where the resulting test suite is empty, which some of our test
+    // infrastructure treats as an error.
+    result.add(TautologyTest.class);
+    for (Class<?> testClass : Iterables.filter(testClasses, matchClassPredicate)) {
+      result.add(testClass);
+    }
+    return result;
+  }
+
+  /**
+   * Determines if a given class is a test class.
+   *
+   * @param container class to test
+   * @return <code>true</code> if the test is a test class.
+   */
+  private static boolean isTestClass(Class<?> container) {
+    return (isJunit4Test(container) || isJunit3Test(container))
+        && !isSuite(container)
+        && Modifier.isPublic(container.getModifiers())
+        && !Modifier.isAbstract(container.getModifiers());
+  }
+
+  private static boolean isJunit4Test(Class<?> container) {
+    return container.getAnnotation(RunWith.class) != null;
+  }
+
+  private static boolean isJunit3Test(Class<?> container) {
+    return TestCase.class.isAssignableFrom(container);
+  }
+
+  /**
+   * Classes that have a {@code RunWith} annotation for {@link ClasspathSuite} or {@link
+   * CustomSuite} are automatically excluded to avoid picking up the suite class itself.
+   */
+  private static boolean isSuite(Class<?> container) {
+    RunWith runWith = container.getAnnotation(RunWith.class);
+    return (runWith != null)
+        && ((runWith.value() == ClasspathSuite.class) || (runWith.value() == CustomSuite.class));
+  }
+
+  private static class TestClassNameComparator implements Comparator<Class<?>> {
+    @Override
+    public int compare(Class<?> o1, Class<?> o2) {
+      return o1.getName().compareTo(o2.getName());
+    }
+  }
+
+  /**
+   * A test that does nothing and always passes. We have some cases where an empty test suite is
+   * treated as an error, so we use this test to make sure that the test suite is always non-empty.
+   */
+  public static class TautologyTest extends TestCase {
+    public void testThatNothingHappens() {
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/TestThread.java b/src/test/java/com/google/devtools/build/lib/testutil/TestThread.java
new file mode 100644
index 0000000..e043025
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/TestThread.java
@@ -0,0 +1,66 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+
+/**
+ * Test thread implementation that allows the use of assertions within
+ * spawned threads.
+ *
+ * Main test method must call {@link TestThread#joinAndAssertState(long)}
+ * for each spawned test thread.
+ */
+public abstract class TestThread extends Thread {
+  Throwable testException = null;
+  boolean isSucceeded = false;
+
+  /**
+   * Specific test thread implementation overrides this method.
+   */
+  abstract public void runTest() throws Exception;
+
+  @Override public final void run() {
+    try {
+      runTest();
+      isSucceeded = true;
+    } catch (Exception e) {
+      testException = e;
+    } catch (AssertionError e) {
+      testException = e;
+    }
+  }
+
+  /**
+   * Joins test thread (waiting specified number of ms) and validates that
+   * it has been completed successfully.
+   */
+  public void joinAndAssertState(long timeout) throws InterruptedException {
+    join(timeout);
+    Throwable exception = this.testException;
+    if (isAlive()) {
+      exception = new AssertionError (
+          "Test thread " + getName() + " is still alive");
+      exception.setStackTrace(getStackTrace());
+    }
+    if(exception != null) {
+      AssertionError error = new AssertionError("Test thread " + getName() + " failed to execute");
+      error.initCause(exception);
+      throw error;
+    }
+    assertWithMessage("Test thread " + getName() + " has not run successfully").that(isSucceeded)
+        .isTrue();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/TestUtils.java b/src/test/java/com/google/devtools/build/lib/testutil/TestUtils.java
new file mode 100644
index 0000000..2ee5972
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/TestUtils.java
@@ -0,0 +1,152 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.UUID;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadPoolExecutor;
+
+/**
+ * Some static utility functions for testing.
+ */
+public class TestUtils {
+  public static final ThreadPoolExecutor POOL =
+    (ThreadPoolExecutor) Executors.newFixedThreadPool(10);
+
+  public static final UUID ZERO_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000");
+
+  /**
+   * Wait until the {@link System#currentTimeMillis} / 1000 advances.
+   *
+   * This method takes 0-1000ms to run, 500ms on average.
+   */
+  public static void advanceCurrentTimeSeconds() throws InterruptedException {
+    long currentTimeSeconds = System.currentTimeMillis() / 1000;
+    do {
+      Thread.sleep(50);
+    } while (currentTimeSeconds == System.currentTimeMillis() / 1000);
+  }
+
+  public static ThreadPoolExecutor getPool() {
+    return POOL;
+  }
+
+  public static String tmpDir() {
+    return tmpDirFile().getAbsolutePath();
+  }
+
+  static String getUserValue(String key) {
+    String value = System.getProperty(key);
+    if (value == null) {
+      value = System.getenv(key);
+    }
+    return value;
+  }
+
+  public static File tmpDirFile() {
+    File tmpDir;
+
+    // Flag value specified in environment?
+    String tmpDirStr = getUserValue("TEST_TMPDIR");
+
+    if (tmpDirStr != null && tmpDirStr.length() > 0) {
+      tmpDir = new File(tmpDirStr);
+    } else {
+      // Fallback default $TEMP/$USER/tmp/$TESTNAME
+      String baseTmpDir = System.getProperty("java.io.tmpdir");
+      tmpDir = new File(baseTmpDir).getAbsoluteFile();
+
+      // .. Add username
+      String username = System.getProperty("user.name");
+      username = username.replace('/', '_');
+      username = username.replace('\\', '_');
+      username = username.replace('\000', '_');
+      tmpDir = new File(tmpDir, username);
+      tmpDir = new File(tmpDir, "tmp");
+    }
+
+    // Ensure tmpDir exists
+    if (!tmpDir.isDirectory()) {
+      tmpDir.mkdirs();
+    }
+    return tmpDir;
+  }
+
+  public static File makeTempDir() throws IOException {
+    File dir = File.createTempFile(TestUtils.class.getName(), ".temp", tmpDirFile());
+    if (!dir.delete()) {
+      throw new IOException("Cannot remove a temporary file " + dir);
+    }
+    if (!dir.mkdir()) {
+      throw new IOException("Cannot create a temporary directory " + dir);
+    }
+    return dir;
+  }
+
+  public static int getRandomSeed() {
+    // Default value if not running under framework
+    int randomSeed = 301;
+
+    // Value specified in environment by framework?
+    String value = getUserValue("TEST_RANDOM_SEED");
+    if ((value != null) && (value.length() > 0)) {
+      try {
+        randomSeed = Integer.parseInt(value);
+      } catch (NumberFormatException e) {
+        // throw new AssertionError("TEST_RANDOM_SEED must be an integer");
+        throw new RuntimeException("TEST_RANDOM_SEED must be an integer");
+      }
+    }
+
+    return randomSeed;
+  }
+
+  public static byte[] serializeObject(Object obj) throws IOException {
+    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+    try (ObjectOutputStream objectStream = new ObjectOutputStream(outputStream)) {
+      objectStream.writeObject(obj);
+    }
+    return outputStream.toByteArray();
+  }
+
+  public static Object deserializeObject(byte[] buf) throws IOException, ClassNotFoundException {
+    try (ObjectInputStream inStream = new ObjectInputStream(new ByteArrayInputStream(buf))) {
+      return inStream.readObject();
+    }
+  }
+
+  /**
+   * Timeouts for asserting that an arbitrary event occurs eventually.
+   *
+   * <p>In general, it's not appropriate to use a small constant timeout for an arbitrary
+   * computation since there is no guarantee that a snippet of code will execute within a given
+   * amount of time - you are at the mercy of the jvm, your machine, and your OS. In theory we
+   * could try to take all of these factors into account but instead we took the simpler and
+   * obviously correct approach of not having timeouts.
+   *
+   * <p>If a test that uses these timeout values is failing due to a "timeout" at the
+   * 'blaze test' level, it could be because of a legitimate deadlock that would have been caught
+   * if the timeout values below were small. So you can rule out such a deadlock by changing these
+   * values to small numbers (also note that the --test_timeout blaze flag may be useful).
+   */
+  public static final long WAIT_TIMEOUT_MILLISECONDS = Long.MAX_VALUE;
+  public static final long WAIT_TIMEOUT_SECONDS = WAIT_TIMEOUT_MILLISECONDS / 1000;
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/UnknownRuleConfiguredTarget.java b/src/test/java/com/google/devtools/build/lib/testutil/UnknownRuleConfiguredTarget.java
new file mode 100644
index 0000000..d3e5457
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutil/UnknownRuleConfiguredTarget.java
@@ -0,0 +1,59 @@
+// Copyright 2014 Google Inc. 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.testutil;
+
+import com.google.common.collect.ImmutableList;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.actions.FailAction;
+import com.google.devtools.build.lib.analysis.ConfiguredTarget;
+import com.google.devtools.build.lib.analysis.RuleConfiguredTargetBuilder;
+import com.google.devtools.build.lib.analysis.RuleContext;
+import com.google.devtools.build.lib.analysis.Runfiles;
+import com.google.devtools.build.lib.analysis.RunfilesProvider;
+import com.google.devtools.build.lib.collect.nestedset.NestedSet;
+import com.google.devtools.build.lib.collect.nestedset.NestedSetBuilder;
+import com.google.devtools.build.lib.collect.nestedset.Order;
+import com.google.devtools.build.lib.packages.Rule;
+import com.google.devtools.build.lib.rules.RuleConfiguredTargetFactory;
+
+/**
+ * A null implementation of ConfiguredTarget for rules we don't know how to build.
+ */
+public class UnknownRuleConfiguredTarget implements RuleConfiguredTargetFactory {
+
+  @Override
+  public ConfiguredTarget create(RuleContext context)  {
+    // TODO(bazel-team): (2009) why isn't this an error?  It would stop the build more promptly...
+    context.ruleWarning("cannot build " + context.getRule().getRuleClass() + " rules");
+
+    ImmutableList<Artifact> outputArtifacts = context.getOutputArtifacts();
+    NestedSet<Artifact> filesToBuild;
+    if (outputArtifacts.isEmpty()) {
+      // Gotta build *something*...
+      filesToBuild = NestedSetBuilder.create(Order.STABLE_ORDER,
+          context.createOutputArtifact());
+    } else {
+      filesToBuild = NestedSetBuilder.wrap(Order.STABLE_ORDER, outputArtifacts);
+    }
+
+    Rule rule = context.getRule();
+    context.registerAction(new FailAction(context.getActionOwner(),
+        filesToBuild, "cannot build " + rule.getRuleClass() + " rules such as " + rule.getLabel()));
+    return new RuleConfiguredTargetBuilder(context)
+        .setFilesToBuild(filesToBuild)
+        .add(RunfilesProvider.class, RunfilesProvider.simple(Runfiles.EMPTY))
+        .build();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutiltests/JunitTestUtilsTest.java b/src/test/java/com/google/devtools/build/lib/testutiltests/JunitTestUtilsTest.java
new file mode 100644
index 0000000..4bfe0fa
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutiltests/JunitTestUtilsTest.java
@@ -0,0 +1,132 @@
+// Copyright 2014 Google Inc. 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.testutiltests;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.build.lib.testutil.JunitTestUtils.assertContainsSublist;
+import static com.google.devtools.build.lib.testutil.JunitTestUtils.assertDoesNotContainSublist;
+import static org.junit.Assert.fail;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Tests {@link com.google.devtools.build.lib.testutil.JunitTestUtils}.
+ */
+@RunWith(JUnit4.class)
+public class JunitTestUtilsTest {
+
+  @Test
+  public void testAssertContainsSublistSuccess() {
+    List<String> actual = Arrays.asList("a", "b", "c");
+
+    // All single-string combinations.
+    assertContainsSublist(actual, "a");
+    assertContainsSublist(actual, "b");
+    assertContainsSublist(actual, "c");
+
+    // All two-string combinations.
+    assertContainsSublist(actual, "a", "b");
+    assertContainsSublist(actual, "b", "c");
+
+    // The whole list.
+    assertContainsSublist(actual, "a", "b", "c");
+  }
+
+  @Test
+  public void testAssertContainsSublistFailure() {
+    List<String> actual = Arrays.asList("a", "b", "c");
+
+    try {
+      assertContainsSublist(actual, "d");
+      fail("no exception thrown");
+    } catch (AssertionError e) {
+      assertThat(e.getMessage()).startsWith("Did not find [d] as a sublist of [a, b, c]");
+    }
+
+    try {
+      assertContainsSublist(actual, "a", "c");
+      fail("no exception thrown");
+    } catch (AssertionError e) {
+      assertThat(e.getMessage()).startsWith("Did not find [a, c] as a sublist of [a, b, c]");
+    }
+
+    try {
+      assertContainsSublist(actual, "b", "c", "d");
+      fail("no exception thrown");
+    } catch (AssertionError e) {
+      assertThat(e.getMessage()).startsWith("Did not find [b, c, d] as a sublist of [a, b, c]");
+    }
+  }
+
+  @Test
+  public void testAssertDoesNotContainSublistSuccess() {
+    List<String> actual = Arrays.asList("a", "b", "c");
+    assertDoesNotContainSublist(actual, "d");
+    assertDoesNotContainSublist(actual, "a", "c");
+    assertDoesNotContainSublist(actual, "b", "c", "d");
+  }
+
+  @Test
+  public void testAssertDoesNotContainSublistFailure() {
+    List<String> actual = Arrays.asList("a", "b", "c");
+
+    // All single-string combinations.
+    try {
+      assertDoesNotContainSublist(actual, "a");
+      fail("no exception thrown");
+    } catch (AssertionError e) {
+      assertThat(e).hasMessage("Found [a] as a sublist of [a, b, c]");
+    }
+    try {
+      assertDoesNotContainSublist(actual, "b");
+      fail("no exception thrown");
+    } catch (AssertionError e) {
+      assertThat(e).hasMessage("Found [b] as a sublist of [a, b, c]");
+    }
+    try {
+      assertDoesNotContainSublist(actual, "c");
+      fail("no exception thrown");
+    } catch (AssertionError e) {
+      assertThat(e).hasMessage("Found [c] as a sublist of [a, b, c]");
+    }
+
+    // All two-string combinations.
+    try {
+      assertDoesNotContainSublist(actual, "a", "b");
+      fail("no exception thrown");
+    } catch (AssertionError e) {
+      assertThat(e).hasMessage("Found [a, b] as a sublist of [a, b, c]");
+    }
+    try {
+      assertDoesNotContainSublist(actual, "b", "c");
+      fail("no exception thrown");
+    } catch (AssertionError e) {
+      assertThat(e).hasMessage("Found [b, c] as a sublist of [a, b, c]");
+    }
+
+    // The whole list.
+    try {
+      assertDoesNotContainSublist(actual, "a", "b", "c");
+      fail("no exception thrown");
+    } catch (AssertionError e) {
+      assertThat(e).hasMessage("Found [a, b, c] as a sublist of [a, b, c]");
+    }
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutiltests/TestSizeAnnotationTest.java b/src/test/java/com/google/devtools/build/lib/testutiltests/TestSizeAnnotationTest.java
new file mode 100644
index 0000000..5380cba
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/testutiltests/TestSizeAnnotationTest.java
@@ -0,0 +1,141 @@
+// Copyright 2014 Google Inc. 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.testutiltests;
+
+import static com.google.devtools.build.lib.testutil.Suite.getSize;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.devtools.build.lib.testutil.Suite;
+import com.google.devtools.build.lib.testutil.TestSpec;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests {@link com.google.devtools.build.lib.testutil.Suite#getSize(Class)}.
+ */
+@RunWith(JUnit4.class)
+public class TestSizeAnnotationTest {
+
+  private static class HasNoTestSpecAnnotation {
+
+  }
+
+  @TestSpec(flaky = true)
+  private static class FlakyTestSpecAnnotation {
+
+  }
+
+  @TestSpec(suite = "foo")
+  private static class HasNoSizeAnnotationElement {
+
+  }
+
+  @TestSpec(size = Suite.SMALL_TESTS)
+  private static class IsAnnotatedWithSmallSize {
+
+  }
+
+  @TestSpec(size = Suite.MEDIUM_TESTS)
+  private static class IsAnnotatedWithMediumSize {
+
+  }
+
+  @TestSpec(size = Suite.LARGE_TESTS)
+  private static class IsAnnotatedWithLargeSize {
+
+  }
+
+  private static class SuperclassHasAnnotationButNoSizeElement
+      extends HasNoSizeAnnotationElement {
+
+  }
+
+  @TestSpec(size = Suite.LARGE_TESTS)
+  private static class HasSizeElementAndSuperclassHasAnnotationButNoSizeElement
+      extends HasNoSizeAnnotationElement {
+
+  }
+
+  private static class SuperclassHasAnnotationWithSizeElement
+      extends IsAnnotatedWithSmallSize {
+
+  }
+
+  @TestSpec(size = Suite.LARGE_TESTS)
+  private static class HasSizeElementAndSuperclassHasAnnotationWithSizeElement
+      extends IsAnnotatedWithSmallSize {
+
+  }
+
+  @Test
+  public void testHasNoTestSpecAnnotationIsSmall() {
+    assertEquals(Suite.SMALL_TESTS, getSize(HasNoTestSpecAnnotation.class));
+  }
+
+  @Test
+  public void testHasNoSizeAnnotationElementIsSmall() {
+    assertEquals(Suite.SMALL_TESTS, getSize(HasNoSizeAnnotationElement.class));
+  }
+
+  @Test
+  public void testIsAnnotatedWithSmallSizeIsSmall() {
+    assertEquals(Suite.SMALL_TESTS, getSize(IsAnnotatedWithSmallSize.class));
+  }
+
+  @Test
+  public void testIsAnnotatedWithMediumSizeIsMedium() {
+    assertEquals(Suite.MEDIUM_TESTS, getSize(IsAnnotatedWithMediumSize.class));
+  }
+
+  @Test
+  public void testIsAnnotatedWithLargeSizeIsLarge() {
+    assertEquals(Suite.LARGE_TESTS, getSize(IsAnnotatedWithLargeSize.class));
+  }
+
+  @Test
+  public void testSuperclassHasAnnotationButNoSizeElement() {
+    assertEquals(Suite.SMALL_TESTS, getSize(SuperclassHasAnnotationButNoSizeElement.class));
+  }
+
+  @Test
+  public void testHasSizeElementAndSuperclassHasAnnotationButNoSizeElement() {
+    assertEquals(Suite.LARGE_TESTS,
+        getSize(HasSizeElementAndSuperclassHasAnnotationButNoSizeElement.class));
+  }
+
+  @Test
+  public void testSuperclassHasAnnotationWithSizeElement() {
+    assertEquals(Suite.SMALL_TESTS, getSize(SuperclassHasAnnotationWithSizeElement.class));
+  }
+
+  @Test
+  public void testHasSizeElementAndSuperclassHasAnnotationWithSizeElement() {
+    assertEquals(Suite.LARGE_TESTS,
+        getSize(HasSizeElementAndSuperclassHasAnnotationWithSizeElement.class));
+  }
+
+  @Test
+  public void testIsNotFlaky() {
+    assertFalse(Suite.isFlaky(HasNoTestSpecAnnotation.class));
+  }
+  
+  @Test
+  public void testIsFlaky() {
+    assertTrue(Suite.isFlaky(FlakyTestSpecAnnotation.class));
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/unix/FilesystemUtilsTest.java b/src/test/java/com/google/devtools/build/lib/unix/FilesystemUtilsTest.java
new file mode 100644
index 0000000..48a67c1
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/unix/FilesystemUtilsTest.java
@@ -0,0 +1,76 @@
+// Copyright 2014 Google Inc. 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.unix;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.common.hash.HashCode;
+import com.google.devtools.build.lib.testutil.TestUtils;
+import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.UnixFileSystem;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.File;
+import java.util.HashMap;
+
+/**
+ * This class tests the FilesystemUtils class.
+ */
+@RunWith(JUnit4.class)
+public class FilesystemUtilsTest {
+  private FileSystem testFS;
+  private Path workingDir;
+  private Path testFile;
+
+  @Before
+  public void setUp() throws Exception {
+    testFS = new UnixFileSystem();
+    workingDir = testFS.getPath(new File(TestUtils.tmpDir()).getCanonicalPath());
+    testFile = workingDir.getRelative("test");
+    FileSystemUtils.createEmptyFile(testFile);
+  }
+
+  /**
+   * This test validates that the md5sum() method returns hashes that match the official test
+   * vectors specified in RFC 1321, The MD5 Message-Digest Algorithm.
+   *
+   * @throws Exception
+   */
+  @Test
+  public void testValidateMd5Sum() throws Exception {
+    HashMap<String, String> testVectors = new HashMap<String, String>();
+    testVectors.put("", "d41d8cd98f00b204e9800998ecf8427e");
+    testVectors.put("a", "0cc175b9c0f1b6a831c399e269772661");
+    testVectors.put("abc", "900150983cd24fb0d6963f7d28e17f72");
+    testVectors.put("message digest", "f96b697d7cb7938d525a2f31aaf161d0");
+    testVectors.put("abcdefghijklmnopqrstuvwxyz", "c3fcd3d76192e4007dfb496cca67e13b");
+    testVectors.put("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789",
+        "d174ab98d277d9f5a5611c2c9f419d9f");
+    testVectors.put(
+        "12345678901234567890123456789012345678901234567890123456789012345678901234567890",
+        "57edf4a22be3c955ac49da2e2107b67a");
+
+    for (String testInput : testVectors.keySet()) {
+      FileSystemUtils.writeContentAsLatin1(testFile, testInput);
+      HashCode result = FilesystemUtils.md5sum(testFile.getPathString());
+      assertEquals(result.toString(), testVectors.get(testInput));
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/AnsiStrippingOutputStreamTest.java b/src/test/java/com/google/devtools/build/lib/util/AnsiStrippingOutputStreamTest.java
new file mode 100644
index 0000000..41428cb
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/AnsiStrippingOutputStreamTest.java
@@ -0,0 +1,84 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.ByteArrayOutputStream;
+import java.io.OutputStream;
+import java.io.PrintStream;
+
+/**
+ * Tests for {@link AnsiStrippingOutputStream}.
+ */
+@RunWith(JUnit4.class)
+public class AnsiStrippingOutputStreamTest {
+  ByteArrayOutputStream output;
+  PrintStream input;
+
+  private static final String ESCAPE = "\u001b[";
+
+  @Before
+  public void setUp() throws Exception {
+    output = new ByteArrayOutputStream();
+    OutputStream inputStream = new AnsiStrippingOutputStream(output);
+    input = new PrintStream(inputStream);
+  }
+
+  private String getOutput(String... fragments) throws Exception {
+    for (String fragment: fragments) {
+      input.print(fragment);
+    }
+
+    return new String(output.toByteArray(), "ISO8859-1");
+  }
+
+  @Test
+  public void doesNotFailHorribly() throws Exception {
+    assertEquals("Love", getOutput("Love"));
+  }
+
+  @Test
+  public void canStripAnsiCode() throws Exception {
+    assertEquals("Love", getOutput(ESCAPE + "32mLove" + ESCAPE + "m"));
+  }
+
+  @Test
+  public void recognizesAnsiCodeWhenBrokenUp() throws Exception {
+    assertEquals("Love", getOutput("\u001b", "[", "mLove"));
+  }
+
+  @Test
+  public void handlesOnlyEscCorrectly() throws Exception {
+    assertEquals("\u001bLove", getOutput("\u001bLove"));
+  }
+
+  @Test
+  public void handlesEscInPlaceOfControlCharCorrectly() throws Exception {
+    assertEquals(ESCAPE + "31;42Love",
+        getOutput(ESCAPE + "31;42" + ESCAPE + "1mLove"));
+  }
+
+  @Test
+  public void handlesTwoEscapeSequencesCorrectly() throws Exception {
+    assertEquals("Love",
+        getOutput(ESCAPE + "32m" + ESCAPE + "1m" + "Love"));
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/CommandBuilderTest.java b/src/test/java/com/google/devtools/build/lib/util/CommandBuilderTest.java
new file mode 100644
index 0000000..566da25
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/CommandBuilderTest.java
@@ -0,0 +1,104 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Arrays;
+
+/**
+ * Tests for the {@link CommandBuilder} class.
+ */
+@RunWith(JUnit4.class)
+public class CommandBuilderTest {
+
+  private CommandBuilder linuxBuilder() {
+    return new CommandBuilder(OS.LINUX).useTempDir();
+  }
+
+  private CommandBuilder winBuilder() {
+    return new CommandBuilder(OS.WINDOWS).useTempDir();
+  }
+
+  private void assertArgv(CommandBuilder builder, String... expected) {
+    assertThat(Arrays.asList(builder.build().getCommandLineElements())).containsExactlyElementsIn(
+        Arrays.asList(expected)).inOrder();
+  }
+
+  private void assertWinCmdArgv(CommandBuilder builder, String expected) {
+    assertArgv(builder, "CMD.EXE", "/S", "/E:ON", "/V:ON", "/D", "/C", "\"" + expected + "\"");
+  }
+
+  private void assertFailure(CommandBuilder builder, String expected) {
+    try {
+      builder.build();
+      fail("Expected exception");
+    } catch (Exception e) {
+      assertEquals(expected, e.getMessage());
+    }
+  }
+
+  @Test
+  public void linuxBuilderTest() {
+    assertArgv(linuxBuilder().addArg("abc"), "abc");
+    assertArgv(linuxBuilder().addArg("abc def"), "abc def");
+    assertArgv(linuxBuilder().addArgs("abc", "def"), "abc", "def");
+    assertArgv(linuxBuilder().addArgs(ImmutableList.of("abc", "def")), "abc", "def");
+    assertArgv(linuxBuilder().addArg("abc").useShell(true), "/bin/sh", "-c", "abc");
+    assertArgv(linuxBuilder().addArg("abc def").useShell(true), "/bin/sh", "-c", "abc def");
+    assertArgv(linuxBuilder().addArgs("abc", "def").useShell(true), "/bin/sh", "-c", "abc def");
+    assertArgv(linuxBuilder().addArgs("/bin/sh", "-c", "abc").useShell(true),
+        "/bin/sh", "-c", "abc");
+    assertArgv(linuxBuilder().addArgs("/bin/sh", "-c"), "/bin/sh", "-c");
+    assertArgv(linuxBuilder().addArgs("/bin/bash", "-c"), "/bin/bash", "-c");
+    assertArgv(linuxBuilder().addArgs("/bin/sh", "-c").useShell(true), "/bin/sh", "-c");
+    assertArgv(linuxBuilder().addArgs("/bin/bash", "-c").useShell(true), "/bin/bash", "-c");
+  }
+
+  @Test
+  public void windowsBuilderTest() {
+    assertArgv(winBuilder().addArg("abc.exe"), "abc.exe");
+    assertArgv(winBuilder().addArg("abc.exe -o"), "abc.exe -o");
+    assertArgv(winBuilder().addArg("ABC.EXE"), "ABC.EXE");
+    assertWinCmdArgv(winBuilder().addArg("abc def.exe"), "abc def.exe");
+    assertArgv(winBuilder().addArgs("abc.exe", "def"), "abc.exe", "def");
+    assertArgv(winBuilder().addArgs(ImmutableList.of("abc.exe", "def")), "abc.exe", "def");
+    assertWinCmdArgv(winBuilder().addArgs("abc.exe", "def").useShell(true), "abc.exe def");
+    assertWinCmdArgv(winBuilder().addArg("abc"), "abc");
+    assertWinCmdArgv(winBuilder().addArgs("abc", "def"), "abc def");
+    assertWinCmdArgv(winBuilder().addArgs("/bin/sh", "-c", "abc", "def"), "abc def");
+    assertWinCmdArgv(winBuilder().addArgs("/bin/sh", "-c"), "");
+    assertWinCmdArgv(winBuilder().addArgs("/bin/bash", "-c"), "");
+    assertWinCmdArgv(winBuilder().addArgs("/bin/sh", "-c").useShell(true), "");
+    assertWinCmdArgv(winBuilder().addArgs("/bin/bash", "-c").useShell(true), "");
+  }
+
+  @Test
+  public void failureScenarios() {
+    assertFailure(linuxBuilder(), "At least one argument is expected");
+    assertFailure(new CommandBuilder(OS.UNKNOWN).useTempDir().addArg("a"),
+        "Unidentified operating system");
+    assertFailure(new CommandBuilder(OS.LINUX).addArg("a"),
+        "Working directory must be set");
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/CommandFailureUtilsTest.java b/src/test/java/com/google/devtools/build/lib/util/CommandFailureUtilsTest.java
new file mode 100644
index 0000000..f0c2c4a
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/CommandFailureUtilsTest.java
@@ -0,0 +1,93 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+@RunWith(JUnit4.class)
+public class CommandFailureUtilsTest {
+
+  @Test
+  public void describeCommandError() throws Exception {
+    String[] args = new String[40];
+    args[0] = "some_command";
+    for (int i = 1; i < args.length; i++) {
+      args[i] = "arg" + i;
+    }
+    args[7] = "with spaces"; // Test embedded spaces in argument.
+    args[9] = "*";           // Test shell meta characters.
+    Map<String, String> env = new HashMap<>();
+    env.put("PATH", "/usr/bin:/bin:/sbin");
+    env.put("FOO", "foo");
+    String cwd = "/my/working/directory";
+    String message = CommandFailureUtils.describeCommandError(false, Arrays.asList(args), env, cwd);
+    String verboseMessage = CommandFailureUtils.describeCommandError(true, Arrays.asList(args), env,
+                                                                     cwd);
+    assertEquals(
+        "error executing command some_command arg1 "
+        + "arg2 arg3 arg4 arg5 arg6 'with spaces' arg8 '*' arg10 "
+        + "arg11 arg12 arg13 arg14 arg15 arg16 arg17 arg18 "
+        + "arg19 arg20 arg21 arg22 arg23 arg24 arg25 arg26 "
+        + "arg27 arg28 arg29 arg30 arg31 "
+        + "... (remaining 8 argument(s) skipped)",
+        message);
+    assertEquals(
+        "error executing command \n"
+        + "  (cd /my/working/directory && \\\n"
+        + "  exec env - \\\n"
+        + "    FOO=foo \\\n"
+        + "    PATH=/usr/bin:/bin:/sbin \\\n"
+        + "  some_command arg1 arg2 arg3 arg4 arg5 arg6 'with spaces' arg8 '*' arg10 "
+        + "arg11 arg12 arg13 arg14 arg15 arg16 arg17 arg18 "
+        + "arg19 arg20 arg21 arg22 arg23 arg24 arg25 arg26 "
+        + "arg27 arg28 arg29 arg30 arg31 arg32 arg33 arg34 "
+        + "arg35 arg36 arg37 arg38 arg39)",
+        verboseMessage);
+  }
+
+  @Test
+  public void describeCommandFailure() throws Exception {
+    String[] args = new String[3];
+    args[0] = "/bin/sh";
+    args[1] = "-c";
+    args[2] = "echo Some errors 1>&2; echo Some output; exit 42";
+    Map<String, String> env = new HashMap<>();
+    env.put("FOO", "foo");
+    env.put("PATH", "/usr/bin:/bin:/sbin");
+    String cwd = null;
+    String message = CommandFailureUtils.describeCommandFailure(false, Arrays.asList(args),
+                                                                env, cwd);
+    String verboseMessage = CommandFailureUtils.describeCommandFailure(true, Arrays.asList(args),
+                                                                       env, cwd);
+    assertEquals(
+        "sh failed: error executing command "
+        + "/bin/sh -c 'echo Some errors 1>&2; echo Some output; exit 42'",
+        message);
+    assertEquals(
+        "sh failed: error executing command \n"
+        + "  (exec env - \\\n"
+        + "    FOO=foo \\\n"
+        + "    PATH=/usr/bin:/bin:/sbin \\\n"
+        + "  /bin/sh -c 'echo Some errors 1>&2; echo Some output; exit 42')",
+        verboseMessage);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/CommandUtilsTest.java b/src/test/java/com/google/devtools/build/lib/util/CommandUtilsTest.java
new file mode 100644
index 0000000..c7639a2
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/CommandUtilsTest.java
@@ -0,0 +1,108 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.Maps;
+import com.google.devtools.build.lib.shell.Command;
+import com.google.devtools.build.lib.shell.CommandException;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.File;
+import java.util.Map;
+
+@RunWith(JUnit4.class)
+public class CommandUtilsTest {
+
+  @Test
+  public void longCommand() throws Exception {
+    String[] args = new String[40];
+    args[0] = "this_command_will_not_be_found";
+    for (int i = 1; i < args.length; i++) {
+      args[i] = "arg" + i;
+    }
+    Map<String, String> env = Maps.newTreeMap();
+    env.put("PATH", "/usr/bin:/bin:/sbin");
+    env.put("FOO", "foo");
+    File directory = new File("/tmp");
+    try {
+      new Command(args, env, directory).execute();
+      fail();
+    } catch (CommandException exception) {
+      String message = CommandUtils.describeCommandError(false, exception.getCommand());
+      String verboseMessage = CommandUtils.describeCommandError(true, exception.getCommand());
+      assertEquals(
+          "error executing command this_command_will_not_be_found arg1 "
+          + "arg2 arg3 arg4 arg5 arg6 arg7 arg8 arg9 arg10 "
+          + "arg11 arg12 arg13 arg14 arg15 arg16 arg17 arg18 "
+          + "arg19 arg20 arg21 arg22 arg23 arg24 arg25 arg26 "
+          + "arg27 arg28 arg29 arg30 "
+          + "... (remaining 9 argument(s) skipped)",
+          message);
+      assertEquals(
+          "error executing command \n"
+          + "  (cd /tmp && \\\n"
+          + "  exec env - \\\n"
+          + "    FOO=foo \\\n"
+          + "    PATH=/usr/bin:/bin:/sbin \\\n"
+          + "  this_command_will_not_be_found arg1 "
+          + "arg2 arg3 arg4 arg5 arg6 arg7 arg8 arg9 arg10 "
+          + "arg11 arg12 arg13 arg14 arg15 arg16 arg17 arg18 "
+          + "arg19 arg20 arg21 arg22 arg23 arg24 arg25 arg26 "
+          + "arg27 arg28 arg29 arg30 arg31 arg32 arg33 arg34 "
+          + "arg35 arg36 arg37 arg38 arg39)",
+          verboseMessage);
+    }
+  }
+
+  @Test
+  public void failingCommand() throws Exception {
+    String[] args = new String[3];
+    args[0] = "/bin/sh";
+    args[1] = "-c";
+    args[2] = "echo Some errors 1>&2; echo Some output; exit 42";
+    Map<String, String> env = Maps.newTreeMap();
+    env.put("FOO", "foo");
+    env.put("PATH", "/usr/bin:/bin:/sbin");
+    try {
+      new Command(args, env, null).execute();
+      fail();
+    } catch (CommandException exception) {
+      String message = CommandUtils.describeCommandFailure(false, exception);
+      String verboseMessage = CommandUtils.describeCommandFailure(true, exception);
+      assertEquals(
+          "sh failed: error executing command " +
+          "/bin/sh -c 'echo Some errors 1>&2; echo Some output; exit 42': " +
+          "Process exited with status 42\n" +
+          "Some output\n" +
+          "Some errors\n",
+          message);
+      assertEquals(
+          "sh failed: error executing command \n" +
+          "  (exec env - \\\n" +
+          "    FOO=foo \\\n" +
+          "    PATH=/usr/bin:/bin:/sbin \\\n" +
+          "  /bin/sh -c 'echo Some errors 1>&2; echo Some output; exit 42'): " +
+          "Process exited with status 42\n" +
+          "Some output\n" +
+          "Some errors\n",
+          verboseMessage);
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/DependencySetTest.java b/src/test/java/com/google/devtools/build/lib/util/DependencySetTest.java
new file mode 100644
index 0000000..40edf36
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/DependencySetTest.java
@@ -0,0 +1,230 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.Sets;
+import com.google.devtools.build.lib.testutil.MoreAsserts;
+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.util.FsApparatus;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Collection;
+
+@RunWith(JUnit4.class)
+public class DependencySetTest {
+
+  private FsApparatus scratch = FsApparatus.newInMemory();
+
+  private DependencySet newDependencySet() {
+    return new DependencySet(scratch.fs().getRootDirectory());
+  }
+
+  @Test
+  public void dotDParser_simple() throws Exception {
+    PathFragment file1 = new PathFragment("/usr/local/blah/blah/genhello/hello.cc");
+    PathFragment file2 = new PathFragment("/usr/local/blah/blah/genhello/hello.h");
+    String filename = "hello.o";
+    Path dotd = scratch.file("/tmp/foo.d",
+        filename + ": \\",
+        " " + file1 + " \\",
+        " " + file2 + " ");
+    DependencySet depset = newDependencySet().read(dotd);
+    MoreAsserts.assertSameContents(Sets.newHashSet(file1, file2),
+                       depset.getDependencies());
+    assertEquals(depset.getOutputFileName(), filename);
+  }
+
+  @Test
+  public void dotDParser_simple_crlf() throws Exception {
+    PathFragment file1 = new PathFragment("/usr/local/blah/blah/genhello/hello.cc");
+    PathFragment file2 = new PathFragment("/usr/local/blah/blah/genhello/hello.h");
+    String filename = "hello.o";
+    Path dotd = scratch.file("/tmp/foo.d",
+        filename + ": \\\r",
+        " " + file1 + " \\\r",
+        " " + file2 + " ");
+    DependencySet depset = newDependencySet().read(dotd);
+    MoreAsserts.assertSameContents(Sets.newHashSet(file1, file2),
+                       depset.getDependencies());
+    assertEquals(depset.getOutputFileName(), filename);
+  }
+
+  @Test
+  public void dotDParser_simple_cr() throws Exception {
+    PathFragment file1 = new PathFragment("/usr/local/blah/blah/genhello/hello.cc");
+    PathFragment file2 = new PathFragment("/usr/local/blah/blah/genhello/hello.h");
+    String filename = "hello.o";
+    Path dotd = scratch.file("/tmp/foo.d",
+        filename + ": \\\r"
+        + " " + file1 + " \\\r"
+        + " " + file2 + " ");
+    DependencySet depset = newDependencySet().read(dotd);
+    MoreAsserts.assertSameContents(Sets.newHashSet(file1, file2),
+                       depset.getDependencies());
+    assertEquals(depset.getOutputFileName(), filename);
+  }
+
+  @Test
+  public void dotDParser_leading_crlf() throws Exception {
+    PathFragment file1 = new PathFragment("/usr/local/blah/blah/genhello/hello.cc");
+    PathFragment file2 = new PathFragment("/usr/local/blah/blah/genhello/hello.h");
+    String filename = "hello.o";
+    Path dotd = scratch.file("/tmp/foo.d",
+        "\r\n" + filename + ": \\\r\n"
+        + " " + file1 + " \\\r\n"
+        + " " + file2 + " ");
+    DependencySet depset = newDependencySet().read(dotd);
+    MoreAsserts.assertSameContents(Sets.newHashSet(file1, file2),
+                       depset.getDependencies());
+    assertEquals(depset.getOutputFileName(), filename);
+  }
+
+  @Test
+  public void dotDParser_oddFormatting() throws Exception {
+    PathFragment file1 = new PathFragment("/usr/local/blah/blah/genhello/hello.cc");
+    PathFragment file2 = new PathFragment("/usr/local/blah/blah/genhello/hello.h");
+    PathFragment file3 = new PathFragment("/usr/local/blah/blah/genhello/other.h");
+    PathFragment file4 = new PathFragment("/usr/local/blah/blah/genhello/onemore.h");
+    String filename = "hello.o";
+    Path dotd = scratch.file("/tmp/foo.d",
+        filename + ": " + file1 + " \\",
+        " " + file2 + "\\",
+        " " + file3 + " " + file4);
+    DependencySet depset = newDependencySet().read(dotd);
+    MoreAsserts.assertSameContents(Sets.newHashSet(file1, file2, file3, file4),
+                       depset.getDependencies());
+    assertEquals(depset.getOutputFileName(), filename);
+  }
+
+  @Test
+  public void dotDParser_relativeFilenames() throws Exception {
+    PathFragment file1 = new PathFragment("hello.cc");
+    PathFragment file2 = new PathFragment("hello.h");
+    String filename = "hello.o";
+    Path dotd = scratch.file("/tmp/foo.d",
+        filename + ": \\",
+        " " + file1 + " \\",
+        " " + file2 + " ");
+    DependencySet depset = newDependencySet().read(dotd);
+    MoreAsserts.assertSameContents(Sets.newHashSet(file1, file2),
+                       depset.getDependencies());
+    assertEquals(depset.getOutputFileName(), filename);
+  }
+
+  @Test
+  public void dotDParser_emptyFile() throws Exception {
+    Path dotd = scratch.file("/tmp/empty.d");
+    DependencySet depset = newDependencySet().read(dotd);
+    Collection<PathFragment> headers = depset.getDependencies();
+    if (!headers.isEmpty()) {
+      fail("Not empty: " + headers.size() + " " + headers);
+    }
+    assertEquals(depset.getOutputFileName(), null);
+  }
+
+  @Test
+  public void dotDParser_multipleTargets() throws Exception {
+    PathFragment file1 = new PathFragment("/usr/local/blah/blah/genhello/hello.cc");
+    PathFragment file2 = new PathFragment("/usr/local/blah/blah/genhello/hello.h");
+    Path dotd = scratch.file("/tmp/foo.d",
+        "hello.o: \\",
+        " " + file1,
+        "hello2.o: \\",
+        " " + file2);
+    MoreAsserts.assertSameContents(Sets.newHashSet(file1, file2),
+        newDependencySet().read(dotd).getDependencies());
+  }
+
+  /*
+   * Regression test: if gcc fails to execute remotely, and we retry locally, then the behavior
+   * of gcc's DEPENDENCIES_OUTPUT option is to append, not overwrite, the .d file. As a result,
+   * during retry, a second stanza is written to the file.
+   *
+   * We handle this by merging all of the stanzas.
+   */
+  @Test
+  public void dotDParser_duplicateStanza() throws Exception {
+    PathFragment file1 = new PathFragment("/usr/local/blah/blah/genhello/hello.cc");
+    PathFragment file2 = new PathFragment("/usr/local/blah/blah/genhello/hello.h");
+    PathFragment file3 = new PathFragment("/usr/local/blah/blah/genhello/other.h");
+    Path dotd = scratch.file("/tmp/foo.d",
+        "hello.o: \\",
+        " " + file1 + " \\",
+        " " + file2 + " ",
+        "hello.o: \\",
+        " " + file1 + " \\",
+        " " + file3 + " ");
+    MoreAsserts.assertSameContents(Sets.newHashSet(file1, file2, file3),
+                       newDependencySet().read(dotd).getDependencies());
+  }
+
+  @Test
+  public void writeSet() throws Exception {
+    PathFragment file1 = new PathFragment("/usr/local/blah/blah/genhello/hello.cc");
+    PathFragment file2 = new PathFragment("/usr/local/blah/blah/genhello/hello.h");
+    PathFragment file3 = new PathFragment("/usr/local/blah/blah/genhello/other.h");
+    String filename = "/usr/local/blah/blah/genhello/hello.o";
+
+    DependencySet depSet1 = newDependencySet();
+    depSet1.addDependency(file1);
+    depSet1.addDependency(file2);
+    depSet1.addDependency(file3);
+    depSet1.setOutputFileName(filename);
+    
+    Path outfile = scratch.path(filename);
+    Path dotd = scratch.path("/usr/local/blah/blah/genhello/hello.d");
+    FileSystemUtils.createDirectoryAndParents(dotd.getParentDirectory());
+    depSet1.write(outfile, ".d");
+
+    String dotdContents = new String(FileSystemUtils.readContentAsLatin1(dotd));
+    String expected =
+        "usr/local/blah/blah/genhello/hello.o:  \\\n" +
+        "  /usr/local/blah/blah/genhello/hello.cc \\\n" +
+        "  /usr/local/blah/blah/genhello/hello.h \\\n" +
+        "  /usr/local/blah/blah/genhello/other.h\n";
+    assertEquals(expected, dotdContents);
+    assertEquals(filename, depSet1.getOutputFileName());
+  }
+
+  @Test
+  public void writeReadSet() throws Exception {
+    String filename = "/usr/local/blah/blah/genhello/hello.d";
+    PathFragment file1 = new PathFragment("/usr/local/blah/blah/genhello/hello.cc");
+    PathFragment file2 = new PathFragment("/usr/local/blah/blah/genhello/hello.h");
+    PathFragment file3 = new PathFragment("/usr/local/blah/blah/genhello/other.h");
+    DependencySet depSet1 = newDependencySet();
+    depSet1.addDependency(file1);
+    depSet1.addDependency(file2);
+    depSet1.addDependency(file3);
+    depSet1.setOutputFileName(filename);
+
+    Path dotd = scratch.path(filename);
+    FileSystemUtils.createDirectoryAndParents(dotd.getParentDirectory());
+    depSet1.write(dotd, ".d");
+    
+    DependencySet depSet2 = newDependencySet().read(dotd);
+    assertEquals(depSet1, depSet2);
+    // due to how pic.d files are written, absolute paths are changed into relatives
+    assertEquals(depSet1.getOutputFileName(), "/" + depSet2.getOutputFileName());
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/DependencySetWindowsTest.java b/src/test/java/com/google/devtools/build/lib/util/DependencySetWindowsTest.java
new file mode 100644
index 0000000..cdda6d1
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/DependencySetWindowsTest.java
@@ -0,0 +1,90 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import com.google.common.collect.Sets;
+import com.google.devtools.build.lib.testutil.MoreAsserts;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.build.lib.vfs.util.FsApparatus;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Set;
+
+@RunWith(JUnit4.class)
+public class DependencySetWindowsTest {
+
+  private FsApparatus scratch = FsApparatus.newInMemory();
+
+  private DependencySet newDependencySet() {
+    return new DependencySet(scratch.fs().getRootDirectory());
+  }
+
+  @Test
+  public void dotDParser_windowsPaths() throws Exception {
+    Path dotd = scratch.file("/tmp/foo.d",
+        "bazel-out/hello-lib/cpp/hello-lib.o: \\",
+        " cpp/hello-lib.cc cpp/hello-lib.h c:\\mingw\\include\\stdio.h \\",
+        " c:\\mingw\\include\\_mingw.h \\",
+        " c:\\mingw\\lib\\gcc\\mingw32\\4.8.1\\include\\stdarg.h");
+
+    Set<PathFragment> expected = Sets.newHashSet(
+        new PathFragment("cpp/hello-lib.cc"),
+        new PathFragment("cpp/hello-lib.h"),
+        new PathFragment("C:/mingw/include/stdio.h"),
+        new PathFragment("C:/mingw/include/_mingw.h"),
+        new PathFragment("C:/mingw/lib/gcc/mingw32/4.8.1/include/stdarg.h"));
+
+    MoreAsserts.assertSameContents(expected,
+        newDependencySet().read(dotd).getDependencies());
+  }
+
+  @Test
+  public void dotDParser_windowsPathsWithSpaces() throws Exception {
+    Path dotd = scratch.file("/tmp/foo.d",
+        "bazel-out/hello-lib/cpp/hello-lib.o: \\",
+        "C:\\Program\\ Files\\ (x86)\\LLVM\\stddef.h");
+    MoreAsserts.assertSameContents(
+        Sets.newHashSet(new PathFragment("C:/Program Files (x86)/LLVM/stddef.h")),
+        newDependencySet().read(dotd).getDependencies());
+  }
+
+  @Test
+  public void dotDParser_mixedWindowsPaths() throws Exception {
+    // This is (slightly simplified) actual output from clang. Yes, clang will happily mix
+    // forward slashes and backslashes in a single path, not to mention using backslashes as
+    // separators next to backslashes as escape characters.
+    Path dotd = scratch.file("/tmp/foo.d",
+        "bazel-out/hello-lib/cpp/hello-lib.o: \\",
+        "cpp/hello-lib.cc cpp/hello-lib.h /mingw/include\\stdio.h \\",
+        "/mingw/include\\_mingw.h \\",
+        "C:\\Program\\ Files\\ (x86)\\LLVM\\bin\\..\\lib\\clang\\3.5.0\\include\\stddef.h \\",
+        "C:\\Program\\ Files\\ (x86)\\LLVM\\bin\\..\\lib\\clang\\3.5.0\\include\\stdarg.h");
+
+    Set<PathFragment> expected = Sets.newHashSet(
+        new PathFragment("cpp/hello-lib.cc"),
+        new PathFragment("cpp/hello-lib.h"),
+        new PathFragment("/mingw/include/stdio.h"),
+        new PathFragment("/mingw/include/_mingw.h"),
+        new PathFragment("C:/Program Files (x86)/LLVM/lib/clang/3.5.0/include/stddef.h"),
+        new PathFragment("C:/Program Files (x86)/LLVM/lib/clang/3.5.0/include/stdarg.h"));
+
+    MoreAsserts.assertSameContents(expected,
+        newDependencySet().read(dotd).getDependencies());
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/FileTypeTest.java b/src/test/java/com/google/devtools/build/lib/util/FileTypeTest.java
new file mode 100644
index 0000000..301875c
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/FileTypeTest.java
@@ -0,0 +1,244 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Predicate;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.devtools.build.lib.util.FileType.HasFilename;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.List;
+
+/**
+ * Test for {@link FileType} and {@link FileTypeSet}.
+ */
+@RunWith(JUnit4.class)
+public class FileTypeTest {
+  private static final FileType CFG = FileType.of(".cfg");
+  private static final FileType HTML = FileType.of(".html");
+  private static final FileType TEXT = FileType.of(".txt");
+  private static final FileType CPP_SOURCE = FileType.of(".cc", ".cpp", ".cxx", ".C");
+  private static final FileType JAVA_SOURCE = FileType.of(".java");
+  private static final FileType PYTHON_SOURCE = FileType.of(".py");
+
+  private static final class HasFilenameImpl implements HasFilename {
+    private final String path;
+
+    private HasFilenameImpl(String path) {
+      this.path = path;
+    }
+
+    @Override
+    public String getFilename() {
+      return path;
+    }
+
+    @Override
+    public String toString() {
+      return path;
+    }
+  }
+
+  @Test
+  public void simpleDotMatch() {
+    assertTrue(TEXT.matches("readme.txt"));
+  }
+
+  @Test
+  public void doubleDotMatches() {
+    assertTrue(TEXT.matches("read.me.txt"));
+  }
+
+  @Test
+  public void noExtensionMatches() {
+    assertTrue(FileType.NO_EXTENSION.matches("hello"));
+    assertTrue(FileType.NO_EXTENSION.matches("/path/to/hello"));
+  }
+
+  @Test
+  public void picksLastExtension() {
+    assertTrue(TEXT.matches("server.cfg.txt"));
+  }
+
+  @Test
+  public void onlyExtensionStillMatches() {
+    assertTrue(TEXT.matches(".txt"));
+  }
+
+  @Test
+  public void handlesPathObjects() {
+    Path readme = new InMemoryFileSystem().getPath("/readme.txt");
+    assertTrue(TEXT.matches(readme));
+  }
+
+  @Test
+  public void handlesPathFragmentObjects() {
+    PathFragment readme = new PathFragment("some/where/readme.txt");
+    assertTrue(TEXT.matches(readme));
+  }
+
+  @Test
+  public void fileTypeSetContains() {
+    FileTypeSet allowedTypes = FileTypeSet.of(TEXT, HTML);
+
+    assertTrue(allowedTypes.matches("readme.txt"));
+    assertTrue(!allowedTypes.matches("style.css"));
+  }
+
+  private List<HasFilename> getArtifacts() {
+    return Lists.<HasFilename>newArrayList(
+        new HasFilenameImpl("Foo.java"),
+        new HasFilenameImpl("bar.cc"),
+        new HasFilenameImpl("baz.py"));
+  }
+
+  private String filterAll(FileType... fileTypes) {
+    return Joiner.on(" ").join(FileType.filter(getArtifacts(), fileTypes));
+  }
+
+  @Test
+  public void justJava() {
+    assertEquals("Foo.java", filterAll(JAVA_SOURCE));
+  }
+
+  @Test
+  public void javaAndCpp() {
+    assertEquals("Foo.java bar.cc", filterAll(JAVA_SOURCE, CPP_SOURCE));
+  }
+
+  @Test
+  public void allThree() {
+    assertEquals("Foo.java bar.cc baz.py", filterAll(JAVA_SOURCE, CPP_SOURCE, PYTHON_SOURCE));
+  }
+
+  private HasFilename filename(final String name) {
+    return new HasFilename() {
+      @Override
+      public String getFilename() {
+        return name;
+      }
+    };
+  }
+
+  @Test
+  public void checkingSingleWithTypePredicate() throws Exception {
+    FileType.HasFilename item = filename("config.txt");
+
+    assertTrue(FileType.contains(item, TEXT));
+    assertFalse(FileType.contains(item, CFG));
+  }
+
+  @Test
+  public void checkingListWithTypePredicate() throws Exception {
+    ImmutableList<FileType.HasFilename> unfiltered = ImmutableList.of(
+        filename("config.txt"),
+        filename("index.html"),
+        filename("README.txt"));
+
+    assertTrue(FileType.contains(unfiltered, TEXT));
+    assertFalse(FileType.contains(unfiltered, CFG));
+  }
+
+  @Test
+  public void filteringWithTypePredicate() throws Exception {
+    ImmutableList<FileType.HasFilename> unfiltered = ImmutableList.of(
+        filename("config.txt"),
+        filename("index.html"),
+        filename("README.txt"),
+        filename("archive.zip"));
+
+    assertThat(FileType.filter(unfiltered, TEXT)).containsExactly(unfiltered.get(0),
+        unfiltered.get(2)).inOrder();
+  }
+
+  @Test
+  public void filteringWithMatcherPredicate() throws Exception {
+    ImmutableList<FileType.HasFilename> unfiltered = ImmutableList.of(
+        filename("config.txt"),
+        filename("index.html"),
+        filename("README.txt"),
+        filename("archive.zip"));
+
+    Predicate<String> textFileTypeMatcher = new Predicate<String>() {
+      @Override
+      public boolean apply(String input) {
+        return TEXT.matches(input);
+      }
+    };
+
+    assertThat(FileType.filter(unfiltered, textFileTypeMatcher)).containsExactly(unfiltered.get(0),
+        unfiltered.get(2)).inOrder();
+  }
+
+  @Test
+  public void filteringWithAlwaysFalse() throws Exception {
+    ImmutableList<FileType.HasFilename> unfiltered = ImmutableList.of(
+        filename("config.txt"),
+        filename("index.html"),
+        filename("binary"),
+        filename("archive.zip"));
+
+    assertThat(FileType.filter(unfiltered, FileTypeSet.NO_FILE)).isEmpty();
+  }
+
+  @Test
+  public void filteringWithAlwaysTrue() throws Exception {
+    ImmutableList<FileType.HasFilename> unfiltered = ImmutableList.of(
+        filename("config.txt"),
+        filename("index.html"),
+        filename("binary"),
+        filename("archive.zip"));
+
+    assertThat(FileType.filter(unfiltered, FileTypeSet.ANY_FILE)).containsExactly(unfiltered.get(0),
+        unfiltered.get(1), unfiltered.get(2), unfiltered.get(3)).inOrder();
+  }
+
+  @Test
+  public void exclusionWithTypePredicate() throws Exception {
+    ImmutableList<FileType.HasFilename> unfiltered = ImmutableList.of(
+        filename("config.txt"),
+        filename("index.html"),
+        filename("README.txt"),
+        filename("server.cfg"));
+
+    assertThat(FileType.except(unfiltered, TEXT)).containsExactly(unfiltered.get(1),
+        unfiltered.get(3)).inOrder();
+  }
+
+  @Test
+  public void listFiltering() throws Exception {
+    ImmutableList<FileType.HasFilename> unfiltered = ImmutableList.of(
+        filename("config.txt"),
+        filename("index.html"),
+        filename("README.txt"),
+        filename("server.cfg"));
+    FileTypeSet filter = FileTypeSet.of(HTML, CFG);
+
+    assertThat(FileType.filterList(unfiltered, filter)).containsExactly(unfiltered.get(1),
+        unfiltered.get(3)).inOrder();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/FingerprintTest.java b/src/test/java/com/google/devtools/build/lib/util/FingerprintTest.java
new file mode 100644
index 0000000..5158019
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/FingerprintTest.java
@@ -0,0 +1,137 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Tests for Fingerprint.
+ */
+@RunWith(JUnit4.class)
+public class FingerprintTest {
+
+  private static void assertFingerprintsDiffer(List<String> list1, List<String>list2) {
+    Fingerprint f1 = new Fingerprint();
+    Fingerprint f1Latin1 = new Fingerprint();
+    for (String s : list1) {
+      f1.addString(s);
+      f1Latin1.addStringLatin1(s);
+    }
+    Fingerprint f2 = new Fingerprint();
+    Fingerprint f2Latin1 = new Fingerprint();
+    for (String s : list2) {
+      f2.addString(s);
+      f2Latin1.addStringLatin1(s);
+    }
+    assertThat(f1.hexDigestAndReset()).isNotEqualTo(f2.hexDigestAndReset());
+    assertThat(f1Latin1.hexDigestAndReset()).isNotEqualTo(f2Latin1.hexDigestAndReset());
+  }
+
+  // You can validate the md5 of the simple string against
+  // echo -n 'Hello World!'| md5sum
+  @Test
+  public void bytesFingerprint() {
+    assertThat("ed076287532e86365e841e92bfc50d8c").isEqualTo(
+        new Fingerprint().addBytes("Hello World!".getBytes(UTF_8)).hexDigestAndReset());
+    assertThat("ed076287532e86365e841e92bfc50d8c").isEqualTo(Fingerprint.md5Digest("Hello World!"));
+  }
+
+  @Test
+  public void otherStringFingerprint() {
+    assertFingerprintsDiffer(ImmutableList.of("Hello World!"),
+                             ImmutableList.of("Goodbye World."));
+  }
+
+  @Test
+  public void multipleUpdatesDiffer() throws Exception {
+    assertFingerprintsDiffer(ImmutableList.of("Hello ", "World!"),
+                             ImmutableList.of("Hello World!"));
+  }
+
+  @Test
+  public void multipleUpdatesShiftedDiffer() throws Exception {
+    assertFingerprintsDiffer(ImmutableList.of("Hello ", "World!"),
+                             ImmutableList.of("Hello", " World!"));
+  }
+
+  @Test
+  public void listFingerprintNotSameAsIndividualElements() throws Exception {
+    Fingerprint f1 = new Fingerprint();
+    f1.addString("Hello ");
+    f1.addString("World!");
+    Fingerprint f2 = new Fingerprint();
+    f2.addStrings(ImmutableList.of("Hello ", "World!"));
+    assertThat(f1.hexDigestAndReset()).isNotEqualTo(f2.hexDigestAndReset());
+  }
+
+  @Test
+  public void mapFingerprintNotSameAsIndividualElements() throws Exception {
+    Fingerprint f1 = new Fingerprint();
+    Map<String, String> map = new HashMap<>();
+    map.put("Hello ", "World!");
+    f1.addStringMap(map);
+    Fingerprint f2 = new Fingerprint();
+    f2.addStrings(ImmutableList.of("Hello ", "World!"));
+    assertThat(f1.hexDigestAndReset()).isNotEqualTo(f2.hexDigestAndReset());
+  }
+
+  @Test
+  public void toStringTest() throws Exception {
+    Fingerprint f1 = new Fingerprint();
+    f1.addString("Hello ");
+    f1.addString("World!");
+    String fp = f1.hexDigestAndReset();
+    Fingerprint f2 = new Fingerprint();
+    f2.addString("Hello ");
+    // make sure that you can call toString on the intermediate result
+    // and continue with the operation.
+    assertThat(fp).isNotEqualTo(f2.toString());
+    f2.addString("World!");
+    assertThat(fp).isEqualTo(f2.hexDigestAndReset());
+  }
+
+  @Test
+  public void addBoolean() throws Exception {
+    String f1 = new Fingerprint().addBoolean(true).hexDigestAndReset();
+    String f2 = new Fingerprint().addBoolean(false).hexDigestAndReset();
+    String f3 = new Fingerprint().addBoolean(true).hexDigestAndReset();
+
+    assertThat(f1).isEqualTo(f3);
+    assertThat(f1).isNotEqualTo(f2);
+  }
+
+  @Test
+  public void addPath() throws Exception {
+    PathFragment pf = new PathFragment("/etc/pwd");
+    assertThat("01cc3eeea3a2f58e447e824f9f62d3d1").isEqualTo(
+        new Fingerprint().addPath(pf).hexDigestAndReset());
+    Path p = new InMemoryFileSystem(BlazeClock.instance()).getPath(pf);
+    assertThat("01cc3eeea3a2f58e447e824f9f62d3d1").isEqualTo(
+        new Fingerprint().addPath(p).hexDigestAndReset());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/GroupedListTest.java b/src/test/java/com/google/devtools/build/lib/util/GroupedListTest.java
new file mode 100644
index 0000000..87cd8c9
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/GroupedListTest.java
@@ -0,0 +1,247 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static com.google.common.truth.Truth.assert_;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.devtools.build.lib.util.GroupedList.GroupedListHelper;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+@RunWith(JUnit4.class)
+public class GroupedListTest {
+  @Test
+  public void empty() {
+    createSizeN(0);
+  }
+
+  @Test
+  public void sizeOne() {
+    createSizeN(1);
+  }
+
+  @Test
+  public void sizeTwo() {
+    createSizeN(2);
+  }
+
+  @Test
+  public void sizeN() {
+    createSizeN(10);
+  }
+
+  private void createSizeN(int size) {
+    List<String> list = new ArrayList<>();
+    for (int i = 0; i < size; i++) {
+      list.add("test" + i);
+    }
+    Object compressedList = createAndCompress(list);
+    assertTrue(Iterables.elementsEqual(iterable(compressedList), list));
+    assertElementsEqual(compressedList, list);
+  }
+
+  @Test
+  public void elementsNotEqualDifferentOrder() {
+    List<String> list = Lists.newArrayList("a", "b", "c");
+    Object compressedList = createAndCompress(list);
+    ArrayList<String> reversed = new ArrayList<>(list);
+    Collections.reverse(reversed);
+    assertFalse(elementsEqual(compressedList, reversed));
+  }
+
+  @Test
+  public void elementsNotEqualDifferentSizes() {
+    for (int size1 = 0; size1 < 10; size1++) {
+      List<String> firstList = new ArrayList<>();
+      for (int i = 0; i < size1; i++) {
+        firstList.add("test" + i);
+      }
+      Object array = createAndCompress(firstList);
+      for (int size2 = 0; size2 < 10; size2++) {
+        List<String> secondList = new ArrayList<>();
+        for (int i = 0; i < size2; i++) {
+          secondList.add("test" + i);
+        }
+        assertEquals(GroupedList.create(array) + ", " + secondList + ", " + size1 + ", " + size2,
+            size1 == size2, elementsEqual(array, secondList));
+      }
+    }
+  }
+
+  @Test
+  public void group() {
+    GroupedList<String> groupedList = new GroupedList<>();
+    assertTrue(groupedList.isEmpty());
+    GroupedListHelper<String> helper = new GroupedListHelper<>();
+    List<ImmutableList<String>> elements = ImmutableList.of(
+        ImmutableList.of("1"),
+        ImmutableList.of("2a", "2b"),
+        ImmutableList.of("3"),
+        ImmutableList.of("4"),
+        ImmutableList.of("5a", "5b", "5c"),
+        ImmutableList.of("6a", "6b", "6c")
+        );
+    List<String> allElts = new ArrayList<>();
+    for (List<String> group : elements) {
+      if (group.size() > 1) {
+        helper.startGroup();
+      }
+      for (String elt : group) {
+        helper.add(elt);
+      }
+      if (group.size() > 1) {
+        helper.endGroup();
+      }
+      allElts.addAll(group);
+    }
+    groupedList.append(helper);
+    assertEquals(allElts.size(), groupedList.size());
+    assertFalse(groupedList.isEmpty());
+    Object compressed = groupedList.compress();
+    assertElementsEqual(compressed, allElts);
+    assert_().that(GroupedList.create(compressed)).containsExactlyElementsIn(elements).inOrder();
+    assert_().that(groupedList).containsExactlyElementsIn(elements).inOrder();
+  }
+
+  @Test
+  public void singletonAndEmptyGroups() {
+    GroupedList<String> groupedList = new GroupedList<>();
+    assertTrue(groupedList.isEmpty());
+    GroupedListHelper<String> helper = new GroupedListHelper<>();
+    @SuppressWarnings("unchecked") // varargs
+    List<ImmutableList<String>> elements = Lists.newArrayList(
+        ImmutableList.of("1"),
+        ImmutableList.<String>of(),
+        ImmutableList.of("2a", "2b"),
+        ImmutableList.of("3")
+        );
+    List<String> allElts = new ArrayList<>();
+    for (List<String> group : elements) {
+      helper.startGroup(); // Start a group even if the group has only one element or is empty.
+      for (String elt : group) {
+        helper.add(elt);
+      }
+      helper.endGroup();
+      allElts.addAll(group);
+    }
+    groupedList.append(helper);
+    assertEquals(allElts.size(), groupedList.size());
+    assertFalse(groupedList.isEmpty());
+    Object compressed = groupedList.compress();
+    assertElementsEqual(compressed, allElts);
+    // Get rid of empty list -- it was not stored in groupedList.
+    elements.remove(1);
+    assert_().that(GroupedList.create(compressed)).containsExactlyElementsIn(elements).inOrder();
+    assert_().that(groupedList).containsExactlyElementsIn(elements).inOrder();
+  }
+
+  @Test
+  public void removeMakesEmpty() {
+    GroupedList<String> groupedList = new GroupedList<>();
+    assertTrue(groupedList.isEmpty());
+    GroupedListHelper<String> helper = new GroupedListHelper<>();
+    @SuppressWarnings("unchecked") // varargs
+    List<List<String>> elements = Lists.newArrayList(
+        (List<String>) ImmutableList.of("1"),
+        ImmutableList.<String>of(),
+        Lists.newArrayList("2a", "2b"),
+        ImmutableList.of("3"),
+        ImmutableList.of("removedGroup1", "removedGroup2"),
+        ImmutableList.of("4")
+        );
+    List<String> allElts = new ArrayList<>();
+    for (List<String> group : elements) {
+      helper.startGroup(); // Start a group even if the group has only one element or is empty.
+      for (String elt : group) {
+        helper.add(elt);
+      }
+      helper.endGroup();
+      allElts.addAll(group);
+    }
+    groupedList.append(helper);
+    Set<String> removed = ImmutableSet.of("2a", "3", "removedGroup1", "removedGroup2");
+    groupedList.remove(removed);
+    Object compressed = groupedList.compress();
+    allElts.removeAll(removed);
+    assertElementsEqual(compressed, allElts);
+    elements.get(2).remove("2a");
+    elements.remove(ImmutableList.of("3"));
+    elements.remove(ImmutableList.of());
+    elements.remove(ImmutableList.of("removedGroup1", "removedGroup2"));
+    assert_().that(GroupedList.create(compressed)).containsExactlyElementsIn(elements).inOrder();
+    assert_().that(groupedList).containsExactlyElementsIn(elements).inOrder();
+  }
+
+  @Test
+  public void removeGroupFromSmallList() {
+    GroupedList<String> groupedList = new GroupedList<>();
+    assertTrue(groupedList.isEmpty());
+    GroupedListHelper<String> helper = new GroupedListHelper<>();
+    List<List<String>> elements = new ArrayList<>();
+    List<String> group = Lists.newArrayList("1a", "1b", "1c", "1d");
+    elements.add(group);
+    List<String> allElts = new ArrayList<>();
+    helper.startGroup();
+    for (String item : elements.get(0)) {
+      helper.add(item);
+    }
+    allElts.addAll(group);
+    helper.endGroup();
+    groupedList.append(helper);
+    Set<String> removed = ImmutableSet.of("1b", "1c");
+    groupedList.remove(removed);
+    Object compressed = groupedList.compress();
+    allElts.removeAll(removed);
+    assertElementsEqual(compressed, allElts);
+    elements.get(0).removeAll(removed);
+    assert_().that(GroupedList.create(compressed)).containsExactlyElementsIn(elements).inOrder();
+    assert_().that(groupedList).containsExactlyElementsIn(elements).inOrder();
+  }
+
+  private static Object createAndCompress(Collection<String> list) {
+    GroupedList<String> result = new GroupedList<>();
+    result.append(GroupedListHelper.create(list));
+    return result.compress();
+  }
+
+  private static Iterable<String> iterable(Object compressed) {
+    return GroupedList.<String>create(compressed).toSet();
+  }
+
+  private static boolean elementsEqual(Object compressed, Iterable<String> expected) {
+    return Iterables.elementsEqual(GroupedList.<String>create(compressed).toSet(), expected);
+  }
+
+  private static void assertElementsEqual(Object compressed, Iterable<String> expected) {
+    assert_()
+        .that(GroupedList.<String>create(compressed).toSet())
+        .containsExactlyElementsIn(expected)
+        .inOrder();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/JavaClockTest.java b/src/test/java/com/google/devtools/build/lib/util/JavaClockTest.java
new file mode 100644
index 0000000..d5d39c0
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/JavaClockTest.java
@@ -0,0 +1,39 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for the Clock instance based on the Java System class.
+ */
+@RunWith(JUnit4.class)
+public class JavaClockTest {
+
+  @Test
+  public void javaClockIsAdvancing() throws Exception {
+    Clock clock = new JavaClock();
+    long millis = clock.currentTimeMillis();
+    long nanos = clock.nanoTime();
+
+    Thread.sleep(10);
+
+    assertThat(clock.currentTimeMillis()).isNotEqualTo(millis);
+    assertThat(clock.nanoTime()).isNotEqualTo(nanos);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/OptionsUtilsTest.java b/src/test/java/com/google/devtools/build/lib/util/OptionsUtilsTest.java
new file mode 100644
index 0000000..9888061
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/OptionsUtilsTest.java
@@ -0,0 +1,157 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.Lists;
+import com.google.devtools.build.lib.util.OptionsUtils.PathFragmentListConverter;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.common.options.Option;
+import com.google.devtools.common.options.OptionPriority;
+import com.google.devtools.common.options.OptionsBase;
+import com.google.devtools.common.options.OptionsParser;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Test for {@link OptionsUtils}.
+ */
+@RunWith(JUnit4.class)
+public class OptionsUtilsTest {
+
+  public static class IntrospectionExample extends OptionsBase {
+    @Option(name = "alpha",
+            category = "one",
+            defaultValue = "alpha")
+    public String alpha;
+
+    @Option(name = "beta",
+            category = "one",
+            defaultValue = "beta")
+    public String beta;
+
+    @Option(name = "gamma",
+            category = "undocumented",
+            defaultValue = "gamma")
+    public String gamma;
+
+    @Option(name = "delta",
+            category = "undocumented",
+            defaultValue = "delta")
+    public String delta;
+
+    @Option(name = "echo",
+            category = "hidden",
+            defaultValue = "echo")
+    public String echo;
+  }
+
+  @Test
+  public void asStringOfExplicitOptions() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(IntrospectionExample.class);
+    parser.parse("--alpha=no", "--gamma=no", "--echo=no");
+    assertEquals("--alpha=no --gamma=no", OptionsUtils.asShellEscapedString(parser));
+  }
+
+  @Test
+  public void asStringOfExplicitOptionsCorrectSortingByPriority() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(IntrospectionExample.class);
+    parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--alpha=no"));
+    parser.parse(OptionPriority.COMPUTED_DEFAULT, null, Arrays.asList("--beta=no"));
+    assertEquals("--beta=no --alpha=no", OptionsUtils.asShellEscapedString(parser));
+  }
+
+  public static class BooleanOpts extends OptionsBase {
+    @Option(name = "b_one",
+        category = "xyz",
+        defaultValue = "true")
+    public boolean bOne;
+
+    @Option(name = "b_two",
+        category = "123", // Not printed in usage messages!
+        defaultValue = "false")
+    public boolean bTwo;
+  }
+
+  @Test
+  public void asStringOfExplicitOptionsWithBooleans() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(BooleanOpts.class);
+    parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--b_one", "--nob_two"));
+    assertEquals("--b_one --nob_two", OptionsUtils.asShellEscapedString(parser));
+
+    parser = OptionsParser.newOptionsParser(BooleanOpts.class);
+    parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--b_one=true", "--b_two=0"));
+    assertTrue(parser.getOptions(BooleanOpts.class).bOne);
+    assertFalse(parser.getOptions(BooleanOpts.class).bTwo);
+    assertEquals("--b_one --nob_two", OptionsUtils.asShellEscapedString(parser));
+  }
+
+  @Test
+  public void asStringOfExplicitOptionsMultipleOptionsAreMultipleTimes() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(IntrospectionExample.class);
+    parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--alpha=one"));
+    parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--alpha=two"));
+    assertEquals("--alpha=one --alpha=two", OptionsUtils.asShellEscapedString(parser));
+  }
+
+  private static List<PathFragment> list(PathFragment... fragments) {
+    return Lists.newArrayList(fragments);
+  }
+
+  private PathFragment fragment(String string) {
+    return new PathFragment(string);
+  }
+
+  private List<PathFragment> convert(String input) throws Exception {
+    return new PathFragmentListConverter().convert(input);
+  }
+
+  @Test
+  public void emptyStringYieldsEmptyList() throws Exception {
+    assertEquals(list(), convert(""));
+  }
+
+  @Test
+  public void lonelyDotYieldsLonelyDot() throws Exception {
+    assertEquals(list(fragment(".")), convert("."));
+  }
+
+  @Test
+  public void converterSkipsEmptyStrings() throws Exception {
+    assertEquals(list(fragment("foo"), fragment("bar")), convert("foo::bar:"));
+  }
+
+  @Test
+  public void multiplePaths() throws Exception {
+    assertEquals(list(fragment("foo"), fragment("/bar/baz"), fragment("."),
+                 fragment("/tmp/bang")), convert("foo:/bar/baz:.:/tmp/bang"));
+  }
+
+  @Test
+  public void valueisUnmodifiable() throws Exception {
+    try {
+      new PathFragmentListConverter().convert("value").add(new PathFragment("other"));
+      fail("could modify value");
+    } catch (UnsupportedOperationException expected) {}
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/PairTest.java b/src/test/java/com/google/devtools/build/lib/util/PairTest.java
new file mode 100644
index 0000000..f82e12f
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/PairTest.java
@@ -0,0 +1,52 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Objects;
+
+/**
+ * Tests for {@link Pair}.
+ */
+@RunWith(JUnit4.class)
+public class PairTest {
+
+  @Test
+  public void constructor() {
+    Object a = new Object();
+    Object b = new Object();
+    Pair<Object, Object> p = Pair.of(a, b);
+    assertSame(a, p.first);
+    assertSame(b, p.second);
+    assertEquals(Pair.of(a, b), p);
+    assertEquals(Objects.hash(a, b), p.hashCode());
+  }
+
+  @Test
+  public void nullable() {
+    Pair<Object, Object> p = Pair.of(null, null);
+    assertNull(p.first);
+    assertNull(p.second);
+    p.hashCode(); // Should not throw.
+    assertTrue(p.equals(p));
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/PathFragmentFilterTest.java b/src/test/java/com/google/devtools/build/lib/util/PathFragmentFilterTest.java
new file mode 100644
index 0000000..e911324
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/PathFragmentFilterTest.java
@@ -0,0 +1,95 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.devtools.build.lib.vfs.PathFragment;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * A test for {@link PathFragmentFilter}.
+ */
+@RunWith(JUnit4.class)
+public class PathFragmentFilterTest {
+  protected PathFragmentFilter filter = null;
+
+  protected void createFilter(String filterString) {
+    filter = new PathFragmentFilter.PathFragmentFilterConverter().convert(filterString);
+  }
+
+  protected void assertIncluded(String path) {
+    assertTrue(filter.isIncluded(new PathFragment(path)));
+  }
+
+  protected void assertExcluded(String path) {
+    assertFalse(filter.isIncluded(new PathFragment(path)));
+  }
+
+  @Test
+  public void emptyFilter() {
+    createFilter("");
+    assertIncluded("a/b/c");
+    assertIncluded("d");
+  }
+
+  @Test
+  public void inclusions() {
+    createFilter("a/b,c");
+    assertIncluded("a/b");
+    assertIncluded("a/b/c");
+    assertIncluded("c");
+    assertIncluded("c/d");
+    assertExcluded("a");
+    assertExcluded("a/c");
+    assertExcluded("d");
+    assertExcluded("e/f/g");
+  }
+
+  @Test
+  public void exclusions() {
+    createFilter("-a/b,-c");
+    assertExcluded("a/b");
+    assertExcluded("a/b/c");
+    assertExcluded("c");
+    assertExcluded("c/d");
+    assertIncluded("a");
+    assertIncluded("a/c");
+    assertIncluded("d");
+    assertIncluded("e/f/g");
+  }
+
+  @Test
+  public void inclusionsAndExclusions() {
+    createFilter("a,-c,,d,a/b/c,-a/b,a/b/d");
+    assertIncluded("a");
+    assertIncluded("a/c");
+    assertExcluded("a/b");
+    assertExcluded("a/b/c"); // Exclusions take precedence over inclusions. Order is not important.
+    assertExcluded("a/b/d"); // Exclusions take precedence over inclusions. Order is not important.
+    assertExcluded("c");
+    assertExcluded("c/d");
+    assertIncluded("d/e");
+    assertExcluded("e");
+    // When converted back to string, inclusion entries will be put first, followed by exclusion
+    // entries.
+    assertEquals("a,d,a/b/c,a/b/d,-c,-a/b", filter.toString());
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/PersistentMapTest.java b/src/test/java/com/google/devtools/build/lib/util/PersistentMapTest.java
new file mode 100644
index 0000000..44aa538
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/PersistentMapTest.java
@@ -0,0 +1,225 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.util.FsApparatus;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Unit tests for the {@link PersistentMap}.
+ */
+@RunWith(JUnit4.class)
+public class PersistentMapTest {
+  public static class PersistentStringMap extends PersistentMap<String, String> {
+    boolean updateJournal = true;
+    boolean keepJournal = false;
+
+    public PersistentStringMap(Map<String, String> map, Path mapFile,
+        Path journalFile) throws IOException {
+      super(0x0, map, mapFile, journalFile);
+      load();
+    }
+
+    @Override
+    protected String readKey(DataInputStream in) throws IOException {
+      return in.readUTF();
+    }
+    @Override
+    protected String readValue(DataInputStream in) throws IOException {
+      return in.readUTF();
+    }
+    @Override
+    protected void writeKey(String key, DataOutputStream out)
+        throws IOException {
+      out.writeUTF(key);
+    }
+    @Override
+    protected void writeValue(String value, DataOutputStream out)
+        throws IOException {
+      out.writeUTF(value);
+    }
+    @Override
+    protected boolean updateJournal() {
+      return updateJournal;
+    }
+    @Override
+    protected boolean keepJournal() {
+      return keepJournal;
+    }
+  }
+
+  private FsApparatus scratch = FsApparatus.newInMemory();
+
+  private PersistentStringMap map;
+  private Path mapFile;
+  private Path journalFile;
+
+  @Before
+  public void setUp() throws Exception {
+    mapFile = scratch.fs().getPath("/tmp/map.txt");
+    journalFile = scratch.fs().getPath("/tmp/journal.txt");
+    createMap();
+  }
+
+  private void createMap() throws Exception {
+    Map<String, String> map = new HashMap<>();
+    this.map = new PersistentStringMap(map, mapFile, journalFile);
+  }
+
+  @Test
+  public void map() throws Exception {
+    createMap();
+    map.put("foo", "bar");
+    map.put("baz", "bang");
+    assertEquals("bar", map.get("foo"));
+    assertEquals("bang", map.get("baz"));
+    assertEquals(2, map.size());
+    long size = map.save();
+    assertEquals(mapFile.getFileSize(), size);
+    assertEquals("bar", map.get("foo"));
+    assertEquals("bang", map.get("baz"));
+    assertEquals(2, map.size());
+
+    createMap(); // create a new map
+    assertEquals("bar", map.get("foo"));
+    assertEquals("bang", map.get("baz"));
+    assertEquals(2, map.size());
+  }
+
+  @Test
+  public void remove() throws Exception {
+    createMap();
+    map.put("foo", "bar");
+    map.put("baz", "bang");
+    long size = map.save();
+    assertEquals(mapFile.getFileSize(), size);
+    assertFalse(journalFile.exists());
+    map.remove("foo");
+    assertEquals(1, map.size());
+    assertTrue(journalFile.exists());
+    createMap(); // create a new map
+    assertEquals(1, map.size());
+  }
+
+  @Test
+  public void clear() throws Exception {
+    createMap();
+    map.put("foo", "bar");
+    map.put("baz", "bang");
+    map.save();
+    assertTrue(mapFile.exists());
+    assertFalse(journalFile.exists());
+    map.clear();
+    assertEquals(0, map.size());
+    assertTrue(mapFile.exists());
+    assertFalse(journalFile.exists());
+    createMap(); // create a new map
+    assertEquals(0, map.size());
+  }
+
+  @Test
+  public void noUpdateJournal() throws Exception {
+    createMap();
+    map.put("foo", "bar");
+    map.put("baz", "bang");
+    map.save();
+    assertFalse(journalFile.exists());
+    // prevent updating the journal
+    map.updateJournal = false;
+    // remove an entry
+    map.remove("foo");
+    assertEquals(1, map.size());
+    // no journal file written
+    assertFalse(journalFile.exists());
+    createMap(); // create a new map
+    // both entries are still in the map on disk
+    assertEquals(2, map.size());
+  }
+
+  @Test
+  public void keepJournal() throws Exception {
+    createMap();
+    map.put("foo", "bar");
+    map.put("baz", "bang");
+    map.save();
+    assertFalse(journalFile.exists());
+
+    // Keep the journal through the save.
+    map.updateJournal = false;
+    map.keepJournal = true;
+
+    // remove an entry
+    map.remove("foo");
+    assertEquals(1, map.size());
+    // no journal file written
+    assertFalse(journalFile.exists());
+
+    long size = map.save();
+    assertEquals(1, map.size());
+    // The journal must be serialzed on save(), even if !updateJournal.
+    assertTrue(journalFile.exists());
+    assertEquals(journalFile.getFileSize() + mapFile.getFileSize(), size);
+
+    map.load();
+    assertEquals(1, map.size());
+    assertTrue(journalFile.exists());
+
+    createMap(); // create a new map
+    assertEquals(1, map.size());
+
+    map.keepJournal = false;
+    map.save();
+    assertEquals(1, map.size());
+    assertFalse(journalFile.exists());
+  }
+
+  @Test
+  public void multipleJournalUpdates() throws Exception {
+    createMap();
+    map.put("foo", "bar");
+    map.save();
+    assertFalse(journalFile.exists());
+    // add an entry
+    map.put("baz", "bang");
+    assertEquals(2, map.size());
+    // journal file written
+    assertTrue(journalFile.exists());
+    createMap(); // create a new map
+    // both entries are still in the map on disk
+    assertEquals(2, map.size());
+    // add another entry
+    map.put("baz2", "bang2");
+    assertEquals(3, map.size());
+    // journal file written
+    assertTrue(journalFile.exists());
+    createMap(); // create a new map
+    // all three entries are still in the map on disk
+    assertEquals(3, map.size());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/ProcMeminfoParserTest.java b/src/test/java/com/google/devtools/build/lib/util/ProcMeminfoParserTest.java
new file mode 100644
index 0000000..9d04f28
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/ProcMeminfoParserTest.java
@@ -0,0 +1,91 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import com.google.devtools.build.lib.vfs.util.FsApparatus;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.IOException;
+
+/**
+ * Tests for ProcMeminfoParser.
+ */
+@RunWith(JUnit4.class)
+public class ProcMeminfoParserTest {
+
+  private FsApparatus scratch = FsApparatus.newNative();
+
+  @Test
+  public void memInfo() throws IOException {
+    String meminfoContent = StringUtilities.joinLines(
+        "MemTotal:      3091732 kB",
+        "MemFree:       2167344 kB",
+        "Buffers:         60644 kB",
+        "Cached:         509940 kB",
+        "SwapCached:          0 kB",
+        "Active:         636892 kB",
+        "Inactive:       212760 kB",
+        "HighTotal:           0 kB",
+        "HighFree:            0 kB",
+        "LowTotal:      3091732 kB",
+        "LowFree:       2167344 kB",
+        "SwapTotal:     9124880 kB",
+        "SwapFree:      9124880 kB",
+        "Dirty:               0 kB",
+        "Writeback:           0 kB",
+        "AnonPages:      279028 kB",
+        "Mapped:          54404 kB",
+        "Slab:            42820 kB",
+        "PageTables:       5184 kB",
+        "NFS_Unstable:        0 kB",
+        "Bounce:              0 kB",
+        "CommitLimit:  10670744 kB",
+        "Committed_AS:   665840 kB",
+        "VmallocTotal: 34359738367 kB",
+        "VmallocUsed:    300484 kB",
+        "VmallocChunk: 34359437307 kB",
+        "HugePages_Total:     0",
+        "HugePages_Free:      0",
+        "HugePages_Rsvd:      0",
+        "Hugepagesize:     2048 kB",
+        "Bogus: not_a_number",
+        "Bogus2: 1000000000000000000000000000000000000000000000000 kB"
+    );
+
+    String meminfoFile = scratch.file("test_meminfo", meminfoContent).getPathString();
+    ProcMeminfoParser memInfo = new ProcMeminfoParser(meminfoFile);
+
+    assertEquals(2356756, memInfo.getFreeRamKb());
+    assertEquals(509940, memInfo.getRamKb("Cached"));
+    assertEquals(3091732, memInfo.getTotalKb());
+    assertNotAvailable("Bogus", memInfo);
+    assertNotAvailable("Bogus2", memInfo);
+  }
+
+  private static void assertNotAvailable(String field, ProcMeminfoParser memInfo) {
+    try {
+      memInfo.getRamKb(field);
+      fail();
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/RegexFilterTest.java b/src/test/java/com/google/devtools/build/lib/util/RegexFilterTest.java
new file mode 100644
index 0000000..bb502c7
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/RegexFilterTest.java
@@ -0,0 +1,141 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.testing.EqualsTester;
+import com.google.devtools.common.options.OptionsParsingException;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * A test for {@link RegexFilter}.
+ */
+@RunWith(JUnit4.class)
+public class RegexFilterTest {
+  protected RegexFilter filter = null;
+
+  protected RegexFilter createFilter(String filterString) throws OptionsParsingException {
+    filter = new RegexFilter.RegexFilterConverter().convert(filterString);
+    return filter;
+  }
+
+  protected void assertIncluded(String value) {
+    assertTrue(filter.isIncluded(value));
+  }
+
+  protected void assertExcluded(String value) {
+    assertFalse(filter.isIncluded(value));
+  }
+
+  @Test
+  public void emptyFilter() throws Exception {
+    createFilter("");
+    assertIncluded("a/b/c");
+    assertIncluded("d");
+  }
+
+  @Test
+  public void inclusions() throws Exception {
+    createFilter("a/b,+^c,_test$");
+    assertEquals("(?:(?>a/b)|(?>^c)|(?>_test$))", filter.toString());
+    assertIncluded("a/b");
+    assertIncluded("a/b/c");
+    assertIncluded("c");
+    assertIncluded("c/d");
+    assertIncluded("e/a/b");
+    assertIncluded("f/1/2/3/_test");
+    assertExcluded("a");
+    assertExcluded("a/c");
+    assertExcluded("d");
+    assertExcluded("e/f/g");
+    assertExcluded("f/_test2");
+  }
+
+  @Test
+  public void exclusions() throws Exception {
+    createFilter("-a/b,-^c,-_test$");
+    assertEquals("-(?:(?>a/b)|(?>^c)|(?>_test$))", filter.toString());
+    assertExcluded("a/b");
+    assertExcluded("a/b/c");
+    assertExcluded("c");
+    assertExcluded("c/d");
+    assertExcluded("f/a/b/d");
+    assertExcluded("f/a_test");
+    assertIncluded("a");
+    assertIncluded("a/c");
+    assertIncluded("d");
+    assertIncluded("e/f/g");
+    assertIncluded("f/a_test_case");
+  }
+
+  @Test
+  public void inclusionsAndExclusions() throws Exception {
+    createFilter("a,-^c,,-,+,d,+a/b/c,-a/b,a/b/d");
+    assertEquals("(?:(?>a)|(?>d)|(?>a/b/c)|(?>a/b/d)),-(?:(?>^c)|(?>a/b))", filter.toString());
+    assertIncluded("a");
+    assertIncluded("a/c");
+    assertExcluded("a/b");
+    assertExcluded("a/b/c"); // Exclusions take precedence over inclusions. Order is not important.
+    assertExcluded("a/b/d"); // Exclusions take precedence over inclusions. Order is not important.
+    assertExcluded("a/c/a/b/d");
+    assertExcluded("c");
+    assertExcluded("c/d");
+    assertIncluded("d/e");
+    assertExcluded("e");
+  }
+
+  @Test
+  public void commas() throws Exception {
+    createFilter("a\\,b,c\\,d");
+    assertEquals("(?:(?>a\\,b)|(?>c\\,d))", filter.toString());
+    assertIncluded("a,b");
+    assertIncluded("c,d");
+    assertExcluded("a");
+    assertExcluded("b,c");
+    assertExcluded("d");
+  }
+
+  @Test
+  public void invalidExpression() throws Exception {
+    try {
+      createFilter("*a");
+      fail(); // OptionsParsingException should be thrown.
+    } catch (OptionsParsingException e) {
+      assertThat(e.getMessage())
+          .contains("Failed to build valid regular expression: Dangling meta character '*' "
+              + "near index");
+    }
+  }
+
+  @Test
+  public void equals() throws Exception {
+    new EqualsTester()
+      .addEqualityGroup(createFilter("a,b,c"), createFilter("a,b,c"))
+      .addEqualityGroup(createFilter("a,b,c,d"))
+      .addEqualityGroup(createFilter("a,b,-c"), createFilter("a,b,-c"))
+      .addEqualityGroup(createFilter("a,b,-c,-d"))
+      .addEqualityGroup(createFilter("-a,-b,-c"), createFilter("-a,-b,-c"))
+      .addEqualityGroup(createFilter("-a,-b,-c,-d"))
+      .addEqualityGroup(createFilter(""), createFilter(""))
+      .testEquals();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/ResourceFileLoaderTest.java b/src/test/java/com/google/devtools/build/lib/util/ResourceFileLoaderTest.java
new file mode 100644
index 0000000..e821ca1
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/ResourceFileLoaderTest.java
@@ -0,0 +1,49 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.IOException;
+
+/**
+ * A test for {@link ResourceFileLoader}.
+ */
+@RunWith(JUnit4.class)
+public class ResourceFileLoaderTest {
+
+  @Test
+  public void loader() throws IOException {
+    String message = ResourceFileLoader.loadResource(
+        ResourceFileLoaderTest.class, "ResourceFileLoaderTest.message");
+    assertEquals("Hello, world.", message);
+  }
+
+  @Test
+  public void resourceNotFound() {
+    try {
+      ResourceFileLoader.loadResource(ResourceFileLoaderTest.class,
+          "does_not_exist.txt");
+      fail();
+    } catch (IOException e) {
+      assertEquals("does_not_exist.txt not found.", e.getMessage());
+    }
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/ResourceFileLoaderTest.message b/src/test/java/com/google/devtools/build/lib/util/ResourceFileLoaderTest.message
new file mode 100644
index 0000000..c872090
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/ResourceFileLoaderTest.message
@@ -0,0 +1 @@
+Hello, world.
\ No newline at end of file
diff --git a/src/test/java/com/google/devtools/build/lib/util/ShellEscaperTest.java b/src/test/java/com/google/devtools/build/lib/util/ShellEscaperTest.java
new file mode 100644
index 0000000..ac5d413
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/ShellEscaperTest.java
@@ -0,0 +1,83 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static com.google.devtools.build.lib.util.ShellEscaper.escapeString;
+import static org.junit.Assert.assertEquals;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableSet;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Arrays;
+import java.util.Set;
+
+/**
+ * Tests for {@link ShellEscaper}.
+ *
+ * <p>Based on {@code com.google.io.base.shell.ShellUtilsTest}.
+ */
+@RunWith(JUnit4.class)
+public class ShellEscaperTest {
+
+  @Test
+  public void shellEscape() throws Exception {
+    assertEquals("''", escapeString(""));
+    assertEquals("foo", escapeString("foo"));
+    assertEquals("'foo bar'", escapeString("foo bar"));
+    assertEquals("''\\''foo'\\'''", escapeString("'foo'"));
+    assertEquals("'\\'\\''foo\\'\\'''", escapeString("\\'foo\\'"));
+    assertEquals("'${filename%.c}.o'", escapeString("${filename%.c}.o"));
+    assertEquals("'<html!>'", escapeString("<html!>"));
+  }
+
+  @Test
+  public void escapeAll() throws Exception {
+    Set<String> escaped = ImmutableSet.copyOf(
+        ShellEscaper.escapeAll(Arrays.asList("foo", "@bar", "baz'qux")));
+    assertEquals(ImmutableSet.of("foo", "@bar", "'baz'\\''qux'"), escaped);
+  }
+
+  @Test
+  public void escapeJoinAllIntoAppendable() throws Exception {
+    Appendable appendable = ShellEscaper.escapeJoinAll(
+        new StringBuilder("initial"), Arrays.asList("foo", "$BAR"));
+    assertEquals("initialfoo '$BAR'", appendable.toString());
+  }
+
+  @Test
+  public void escapeJoinAllIntoAppendableWithCustomJoiner() throws Exception {
+    Appendable appendable = ShellEscaper.escapeJoinAll(
+        new StringBuilder("initial"), Arrays.asList("foo", "$BAR"), Joiner.on('|'));
+    assertEquals("initialfoo|'$BAR'", appendable.toString());
+  }
+
+  @Test
+  public void escapeJoinAll() throws Exception {
+    String actual = ShellEscaper.escapeJoinAll(
+        Arrays.asList("foo", "@echo:-", "100", "$US", "a b", "\"qu'ot'es\"", "\"quot\"", "\\"));
+    assertEquals("foo @echo:- 100 '$US' 'a b' '\"qu'\\''ot'\\''es\"' '\"quot\"' '\\'", actual);
+  }
+
+  @Test
+  public void escapeJoinAllWithCustomJoiner() throws Exception {
+    String actual = ShellEscaper.escapeJoinAll(
+        Arrays.asList("foo", "@echo:-", "100", "$US", "a b", "\"qu'ot'es\"", "\"quot\"", "\\"),
+        Joiner.on('|'));
+    assertEquals("foo|@echo:-|100|'$US'|'a b'|'\"qu'\\''ot'\\''es\"'|'\"quot\"'|'\\'", actual);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/StringCanonicalizerTest.java b/src/test/java/com/google/devtools/build/lib/util/StringCanonicalizerTest.java
new file mode 100644
index 0000000..64acddc
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/StringCanonicalizerTest.java
@@ -0,0 +1,42 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertSame;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for String canonicalizer.
+ */
+@RunWith(JUnit4.class)
+public class StringCanonicalizerTest {
+
+  @Test
+  public void twoDifferentStringsAreDifferent() {
+    String stringA = StringCanonicalizer.intern("A");
+    String stringB = StringCanonicalizer.intern("B");
+    assertThat(stringA).isNotEqualTo(stringB);
+  }
+
+  @Test
+  public void twoSameStringsAreCanonicalized() {
+    String stringA1 = StringCanonicalizer.intern(new String("A"));
+    String stringA2 = StringCanonicalizer.intern(new String("A"));
+    assertSame(stringA1, stringA2);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/StringIndexerTest.java b/src/test/java/com/google/devtools/build/lib/util/StringIndexerTest.java
new file mode 100644
index 0000000..693e11a
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/StringIndexerTest.java
@@ -0,0 +1,314 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.google.common.base.Function;
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.common.collect.MapMaker;
+import com.google.common.collect.Maps;
+import com.google.devtools.build.lib.testutil.TestUtils;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.List;
+import java.util.SortedMap;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Test for the StringIndexer classes.
+ */
+public abstract class StringIndexerTest {
+
+  private static final int ATTEMPTS = 1000;
+  private SortedMap<Integer, String> mappings;
+
+  protected StringIndexer indexer;
+
+  @Before
+  public void setUp() throws Exception {
+    indexer = newIndexer();
+    mappings = Maps.newTreeMap();
+  }
+
+  protected abstract StringIndexer newIndexer();
+
+  protected void assertSize(int expected) {
+    assertEquals(expected, indexer.size());
+  }
+
+  protected void assertNoIndex(String s) {
+    int size = indexer.size();
+    assertEquals(-1, indexer.getIndex(s));
+    assertEquals(size, indexer.size());
+  }
+
+  protected void assertIndex(int expected, String s) {
+    // System.out.println("Adding " + s + ", expecting " + expected);
+    int index = indexer.getOrCreateIndex(s);
+    // System.out.println(csi);
+    assertEquals(expected, index);
+    mappings.put(expected, s);
+  }
+
+  protected void assertContent() {
+    for (int i = 0; i < indexer.size(); i++) {
+      assertNotNull(mappings.get(i));
+      assertEquals(mappings.get(i), indexer.getStringForIndex(i));
+    }
+  }
+
+  private void assertConcurrentUpdates(Function<Integer, String> keyGenerator) throws Exception {
+    final AtomicInteger safeIndex = new AtomicInteger(-1);
+    List<String> keys = Lists.newArrayListWithCapacity(ATTEMPTS);
+    ThreadPoolExecutor executor = new ThreadPoolExecutor(3, 3, 5, TimeUnit.SECONDS,
+        new ArrayBlockingQueue<Runnable>(ATTEMPTS));
+    synchronized(indexer) {
+      for (int i = 0; i < ATTEMPTS; i++) {
+        final String key = keyGenerator.apply(i);
+        keys.add(key);
+        executor.execute(new Runnable() {
+          @Override
+          public void run() {
+            int index = indexer.getOrCreateIndex(key);
+            if (safeIndex.get() < index) { safeIndex.set(index); }
+            indexer.addString(key);
+          }
+        });
+      }
+    }
+    try {
+      while(!executor.getQueue().isEmpty()) {
+        // Validate that we can execute concurrent queries too.
+        if (safeIndex.get() >= 0) {
+          int index = safeIndex.get();
+          // Retrieve string using random existing index and validate reverse mapping.
+          String key = indexer.getStringForIndex(index);
+          assertNotNull(key);
+          assertEquals(index, indexer.getIndex(key));
+        }
+      }
+    } finally {
+      executor.shutdown();
+      executor.awaitTermination(TestUtils.WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+    }
+    for (String key : keys) {
+      // Validate mapping between keys and indices.
+      assertEquals(key, indexer.getStringForIndex(indexer.getIndex(key)));
+    }
+  }
+
+  @Test
+  public void concurrentAddChildNode() throws Exception {
+    assertConcurrentUpdates(new Function<Integer, String>() {
+      @Override
+      public String apply(Integer from) { return Strings.repeat("a", from + 1); }
+    });
+  }
+
+  @Test
+  public void concurrentSplitNodeSuffix() throws Exception {
+    assertConcurrentUpdates(new Function<Integer, String>() {
+      @Override
+      public String apply(Integer from) { return Strings.repeat("b", ATTEMPTS - from); }
+    });
+  }
+
+  @Test
+  public void concurrentAddBranch() throws Exception {
+    assertConcurrentUpdates(new Function<Integer, String>() {
+      @Override
+      public String apply(Integer from) { return String.format("%08o", from); }
+    });
+  }
+
+  @RunWith(JUnit4.class)
+  public static class CompactStringIndexerTest extends StringIndexerTest {
+    @Override
+    protected StringIndexer newIndexer() {
+      return new CompactStringIndexer(1);
+    }
+
+    @Test
+    public void basicOperations() {
+      assertSize(0);
+      assertNoIndex("abcdef");
+      assertIndex(0, "abcdef"); // root node creation
+      assertIndex(0, "abcdef"); // root node match
+      assertSize(1);
+      assertIndex(2, "abddef"); // node branching, index 1 went to "ab" node.
+      assertSize(3);
+      assertIndex(1, "ab");
+      assertSize(3);
+      assertIndex(3, "abcdefghik"); // new leaf creation
+      assertSize(4);
+      assertIndex(4, "abcdefgh");  // node split
+      assertSize(5);
+      assertNoIndex("a");
+      assertNoIndex("abc");
+      assertNoIndex("abcdefg");
+      assertNoIndex("abcdefghil");
+      assertNoIndex("abcdefghikl");
+      assertContent();
+      indexer.clear();
+      assertSize(0);
+      assertNull(indexer.getStringForIndex(0));
+      assertNull(indexer.getStringForIndex(1000));
+    }
+
+    @Test
+    public void parentIndexUpdate() {
+      assertSize(0);
+      assertIndex(0, "abcdefghik");  // Create 3 nodes with single common parent "abcdefgh".
+      assertIndex(2, "abcdefghlm");  // Index 1 went to "abcdefgh".
+      assertIndex(3, "abcdefghxyz");
+      assertSize(4);
+      assertIndex(5, "abcdpqr"); // Split parent. Index 4 went to "abcd".
+      assertSize(6);
+      assertIndex(1, "abcdefgh"); // Check branch node indices.
+      assertIndex(4, "abcd");
+      assertSize(6);
+      assertContent();
+    }
+
+    @Test
+    public void emptyRootNode() {
+      assertSize(0);
+      assertIndex(0, "abc");
+      assertNoIndex("");
+      assertIndex(2, "def");  // root node key is now empty string and has index 1.
+      assertSize(3);
+      assertIndex(1, "");
+      assertSize(3);
+      assertContent();
+    }
+
+    protected void setupTestContent() {
+      assertSize(0);
+      assertIndex(0, "abcdefghi");  // Create leafs
+      assertIndex(2, "abcdefjkl");
+      assertIndex(3, "abcdefmno");
+      assertIndex(4, "abcdefjklpr");
+      assertIndex(6, "abcdstr");
+      assertIndex(8, "012345");
+      assertSize(9);
+      assertIndex(1, "abcdef");  // Validate inner nodes
+      assertIndex(5, "abcd");
+      assertIndex(7, "");
+      assertSize(9);
+      assertContent();
+    }
+
+    @Test
+    public void dumpContent() {
+      indexer = newIndexer();
+      indexer.addString("abc");
+      String content = indexer.toString();
+      assertThat(content).contains("size = 1");
+      assertThat(content).contains("contentSize = 5");
+      indexer = newIndexer();
+      setupTestContent();
+      content = indexer.toString();
+      assertThat(content).contains("size = 9");
+      assertThat(content).contains("contentSize = 60");
+      System.out.println(indexer);
+    }
+
+    @Test
+    public void addStringResult() {
+      assertSize(0);
+      assertTrue(indexer.addString("abcdef"));
+      assertTrue(indexer.addString("abcdgh"));
+      assertFalse(indexer.addString("abcd"));
+      assertTrue(indexer.addString("ab"));
+    }
+  }
+
+  @RunWith(JUnit4.class)
+  public static class CanonicalStringIndexerTest extends StringIndexerTest{
+    @Override
+    protected StringIndexer newIndexer() {
+      return new CanonicalStringIndexer(new MapMaker().<String, Integer>makeMap(),
+                                        new MapMaker().<Integer, String>makeMap());
+    }
+
+    @Test
+    public void basicOperations() {
+      assertSize(0);
+      assertNoIndex("abcdef");
+      assertIndex(0, "abcdef");
+      assertIndex(0, "abcdef");
+      assertSize(1);
+      assertIndex(1, "abddef");
+      assertSize(2);
+      assertIndex(2, "ab");
+      assertSize(3);
+      assertIndex(3, "abcdefghik");
+      assertSize(4);
+      assertIndex(4, "abcdefgh");
+      assertSize(5);
+      assertNoIndex("a");
+      assertNoIndex("abc");
+      assertNoIndex("abcdefg");
+      assertNoIndex("abcdefghil");
+      assertNoIndex("abcdefghikl");
+      assertContent();
+      indexer.clear();
+      assertSize(0);
+      assertNull(indexer.getStringForIndex(0));
+      assertNull(indexer.getStringForIndex(1000));
+    }
+
+    @Test
+    public void addStringResult() {
+      assertSize(0);
+      assertTrue(indexer.addString("abcdef"));
+      assertTrue(indexer.addString("abcdgh"));
+      assertTrue(indexer.addString("abcd"));
+      assertTrue(indexer.addString("ab"));
+      assertFalse(indexer.addString("ab"));
+    }
+
+    protected void setupTestContent() {
+      assertSize(0);
+      assertIndex(0, "abcdefghi");
+      assertIndex(1, "abcdefjkl");
+      assertIndex(2, "abcdefmno");
+      assertIndex(3, "abcdefjklpr");
+      assertIndex(4, "abcdstr");
+      assertIndex(5, "012345");
+      assertSize(6);
+      assertIndex(6, "abcdef");
+      assertIndex(7, "abcd");
+      assertIndex(8, "");
+      assertIndex(2, "abcdefmno");
+      assertSize(9);
+      assertContent();
+    }
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/StringTrieTest.java b/src/test/java/com/google/devtools/build/lib/util/StringTrieTest.java
new file mode 100644
index 0000000..dcb9205
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/StringTrieTest.java
@@ -0,0 +1,70 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Unit tests for {@link StringTrie}.
+ */
+@RunWith(JUnit4.class)
+public class StringTrieTest {
+  @Test
+  public void empty() {
+    StringTrie<Integer> cut = new StringTrie<>();
+    assertNull(cut.get(""));
+    assertNull(cut.get("a"));
+    assertNull(cut.get("ab"));
+  }
+
+  @Test
+  public void simple() {
+    StringTrie<Integer> cut = new StringTrie<>();
+    cut.put("a", 1);
+    cut.put("b", 2);
+
+    assertNull(cut.get(""));
+    assertEquals(1, cut.get("a").intValue());
+    assertEquals(1, cut.get("ab").intValue());
+    assertEquals(1, cut.get("abc").intValue());
+
+    assertEquals(2, cut.get("b").intValue());
+  }
+
+  @Test
+  public void ancestors() {
+    StringTrie<Integer> cut = new StringTrie<>();
+    cut.put("abc", 3);
+    assertNull(cut.get(""));
+    assertNull(cut.get("a"));
+    assertNull(cut.get("ab"));
+    assertEquals(3, cut.get("abc").intValue());
+    assertEquals(3, cut.get("abcd").intValue());
+
+    cut.put("a", 1);
+    assertEquals(1, cut.get("a").intValue());
+    assertEquals(1, cut.get("ab").intValue());
+    assertEquals(3, cut.get("abc").intValue());
+
+    cut.put("", 0);
+    assertEquals(0, cut.get("").intValue());
+    assertEquals(0, cut.get("b").intValue());
+    assertEquals(1, cut.get("a").intValue());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/StringUtilTest.java b/src/test/java/com/google/devtools/build/lib/util/StringUtilTest.java
new file mode 100644
index 0000000..96d9a53
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/StringUtilTest.java
@@ -0,0 +1,110 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static com.google.devtools.build.lib.util.StringUtil.capitalize;
+import static com.google.devtools.build.lib.util.StringUtil.indent;
+import static com.google.devtools.build.lib.util.StringUtil.joinEnglishList;
+import static com.google.devtools.build.lib.util.StringUtil.splitAndInternString;
+import static com.google.devtools.build.lib.util.StringUtil.stripSuffix;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A test for {@link StringUtil}.
+ */
+@RunWith(JUnit4.class)
+public class StringUtilTest {
+
+  @Test
+  public void testJoinEnglishList() throws Exception {
+    assertEquals("nothing",
+        joinEnglishList(Collections.emptyList()));
+    assertEquals("one",
+        joinEnglishList(Arrays.asList("one")));
+    assertEquals("one or two",
+        joinEnglishList(Arrays.asList("one", "two")));
+    assertEquals("one and two",
+        joinEnglishList(Arrays.asList("one", "two"), "and"));
+    assertEquals("one, two or three",
+        joinEnglishList(Arrays.asList("one", "two", "three")));
+    assertEquals("one, two and three",
+        joinEnglishList(Arrays.asList("one", "two", "three"), "and"));
+    assertEquals("'one', 'two' and 'three'",
+        joinEnglishList(Arrays.asList("one", "two", "three"), "and", "'"));
+  }
+
+  @Test
+  public void splitAndIntern() throws Exception {
+    assertEquals(ImmutableList.of(), splitAndInternString("       "));
+    assertEquals(ImmutableList.of(), splitAndInternString(null));
+    List<String> list1 = splitAndInternString("    x y    z    z");
+    List<String> list2 = splitAndInternString("a z    c z");
+
+    assertEquals(ImmutableList.of("x", "y", "z", "z"), list1);
+    assertEquals(ImmutableList.of("a", "z", "c", "z"), list2);
+    assertSame(list1.get(2), list1.get(3));
+    assertSame(list1.get(2), list2.get(1));
+    assertSame(list2.get(1), list2.get(3));
+  }
+
+  @Test
+  public void listItemsWithLimit() throws Exception {
+    assertEquals("begin/a, b, c/end", StringUtil.listItemsWithLimit(
+        new StringBuilder("begin/"), 3, ImmutableList.of("a", "b", "c")).append("/end").toString());
+
+    assertEquals("begin/a, b, c ...(omitting 2 more item(s))/end", StringUtil.listItemsWithLimit(
+        new StringBuilder("begin/"), 3, ImmutableList.of("a", "b", "c", "d", "e"))
+            .append("/end").toString());
+  }
+
+  @Test
+  public void testIndent() throws Exception {
+    assertEquals("", indent("", 0));
+    assertEquals("", indent("", 1));
+    assertEquals("a", indent("a", 1));
+    assertEquals("\n  a", indent("\na", 2));
+    assertEquals("a\n  b", indent("a\nb", 2));
+    assertEquals("a\n b\n c\n d", indent("a\nb\nc\nd", 1));
+    assertEquals("\n ", indent("\n", 1));
+  }
+
+  @Test
+  public void testStripSuffix() throws Exception {
+    assertEquals("", stripSuffix("", ""));
+    assertEquals(null, stripSuffix("", "a"));
+    assertEquals("a", stripSuffix("a", ""));
+    assertEquals("a", stripSuffix("aa", "a"));
+    assertEquals(null, stripSuffix("ab", "c"));
+  }
+
+  @Test
+  public void testCapitalize() throws Exception {
+    assertEquals("", capitalize(""));
+    assertEquals("Joe", capitalize("joe"));
+    assertEquals("Joe", capitalize("Joe"));
+    assertEquals("O", capitalize("o"));
+    assertEquals("O", capitalize("O"));
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/StringUtilitiesTest.java b/src/test/java/com/google/devtools/build/lib/util/StringUtilitiesTest.java
new file mode 100644
index 0000000..c8ed641
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/StringUtilitiesTest.java
@@ -0,0 +1,195 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static com.google.devtools.build.lib.util.StringUtilities.combineKeys;
+import static com.google.devtools.build.lib.util.StringUtilities.joinLines;
+import static com.google.devtools.build.lib.util.StringUtilities.layoutTable;
+import static com.google.devtools.build.lib.util.StringUtilities.prettyPrintBytes;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.Maps;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A test for {@link StringUtilities}.
+ */
+@RunWith(JUnit4.class)
+public class StringUtilitiesTest {
+
+  // Tests of StringUtilities.joinLines()
+
+  @Test
+  public void emptyLinesYieldsEmptyString() {
+    assertEquals("", joinLines());
+  }
+
+  @Test
+  public void twoLinesGetjoinedNicely() {
+    assertEquals("line 1\nline 2", joinLines("line 1", "line 2"));
+  }
+
+  @Test
+  public void aTrailingNewlineIsAvailableWhenYouNeedIt() {
+    assertEquals("two lines\nwith trailing newline\n",
+        joinLines("two lines", "with trailing newline", ""));
+  }
+
+  // Tests of StringUtilities.combineKeys()
+
+  /** Simple sanity test of format */
+  @Test
+  public void combineKeysFormat() {
+    assertEquals("<a><b!!c><!<d!>>", combineKeys("a", "b!c", "<d>"));
+  }
+
+  /**
+   * Test that combining different keys gives different results,
+   * i.e. that there are no collisions.
+   * We test all combinations of up to 3 keys from the test_keys
+   * array (defined below).
+   */
+  @Test
+  public void testCombineKeys() {
+    // This map is really just used as a set, but
+    // if the test fails, the values in the map may be
+    // useful for debugging.
+    Map<String,String[]> map = new HashMap<>();
+    for (int numKeys = 0; numKeys <= 3; numKeys++) {
+      testCombineKeys(map, numKeys, new String[numKeys]);
+    }
+  }
+
+  private void testCombineKeys(Map<String,String[]> map,
+        int n, String[] keys) {
+    if (n == 0) {
+      String[] keys_copy = keys.clone();
+      String combined_key = combineKeys(keys_copy);
+      String[] prev_keys = map.put(combined_key, keys_copy);
+      if (prev_keys != null) {
+        fail("combineKeys collision:\n" +
+              "key sequence 1: " + Arrays.deepToString(prev_keys) + "\n" +
+              "key sequence 2: " + Arrays.deepToString(keys_copy) + "\n" +
+              "combined key sequence 1: " + combineKeys(prev_keys) + "\n" +
+              "combined key sequence 2: " + combineKeys(keys_copy) + "\n");
+      }
+    } else {
+      for (String key : test_keys) {
+        keys[n - 1] = key;
+        testCombineKeys(map, n - 1, keys);
+      }
+    }
+  }
+
+  private static final String[] test_keys = {
+        // ordinary strings
+        "", "a", "word", "//depot/foo/bar",
+        // likely delimiter characters
+        " ", ",", "\\", "\"", "\'", "\0", "\u00ff",
+        // strings starting in special delimiter
+        " foo", ",foo", "\\foo", "\"foo", "\'foo", "\0foo", "\u00fffoo",
+        // strings ending in special delimiter
+        "bar ", "bar,", "bar\\", "bar\"", "bar\'", "bar\0", "bar\u00ff",
+        // white-box testing of the delimiters that combineKeys() uses
+        "<", ">", "!", "!<", "!>", "!!", "<!", ">!"
+  };
+
+  @Test
+  public void replaceAllLiteral() throws Exception {
+    assertEquals("ababab",
+                 StringUtilities.replaceAllLiteral("bababa", "ba", "ab"));
+    assertEquals("",
+        StringUtilities.replaceAllLiteral("bababa", "ba", ""));
+    assertEquals("bababa",
+        StringUtilities.replaceAllLiteral("bababa", "", "ab"));
+  }
+
+  @Test
+  public void testLayoutTable() throws Exception {
+    Map<String, String> data = Maps.newTreeMap();
+    data.put("foo", "bar");
+    data.put("bang", "baz");
+    data.put("lengthy key", "lengthy value");
+
+    assertEquals(joinLines("bang: baz",
+                           "foo: bar",
+                           "lengthy key: lengthy value"), layoutTable(data));
+  }
+
+  @Test
+  public void testPrettyPrintBytes() {
+    String[] expected = {
+      "2B",
+      "23B",
+      "234B",
+      "2345B",
+      "23KB",
+      "234KB",
+      "2345KB",
+      "23MB",
+      "234MB",
+      "2345MB",
+      "23456MB",
+      "234GB",
+      "2345GB",
+      "23456GB",
+    };
+    double x = 2.3456;
+    for (int ii = 0; ii < expected.length; ++ii) {
+      assertEquals(expected[ii], prettyPrintBytes((long) x));
+      x = x * 10.0;
+    }
+  }
+
+  @Test
+  public void sanitizeControlChars() {
+    assertEquals("<?>", StringUtilities.sanitizeControlChars("\000"));
+    assertEquals("<?>", StringUtilities.sanitizeControlChars("\001"));
+    assertEquals("\\r", StringUtilities.sanitizeControlChars("\r"));
+    assertEquals(" abc123", StringUtilities.sanitizeControlChars(" abc123"));
+  }
+
+  @Test
+  public void containsSubarray() {
+    assertTrue(StringUtilities.containsSubarray("abcde".toCharArray(), "ab".toCharArray()));
+    assertTrue(StringUtilities.containsSubarray("abcde".toCharArray(), "de".toCharArray()));
+    assertTrue(StringUtilities.containsSubarray("abcde".toCharArray(), "bc".toCharArray()));
+    assertTrue(StringUtilities.containsSubarray("abcde".toCharArray(), "".toCharArray()));
+  }
+
+  @Test
+  public void notContainsSubarray() {
+    assertFalse(StringUtilities.containsSubarray("abc".toCharArray(), "abcd".toCharArray()));
+    assertFalse(StringUtilities.containsSubarray("abc".toCharArray(), "def".toCharArray()));
+    assertFalse(StringUtilities.containsSubarray("abcde".toCharArray(), "bd".toCharArray()));
+  }
+
+  @Test
+  public void toPythonStyleFunctionName() {
+    assertEquals("a", StringUtilities.toPythonStyleFunctionName("a"));
+    assertEquals("a_b", StringUtilities.toPythonStyleFunctionName("aB"));
+    assertEquals("a_b_c", StringUtilities.toPythonStyleFunctionName("aBC"));
+    assertEquals("a_bc_d", StringUtilities.toPythonStyleFunctionName("aBcD"));
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/TargetUtilsTest.java b/src/test/java/com/google/devtools/build/lib/util/TargetUtilsTest.java
new file mode 100644
index 0000000..33b0fe3
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/TargetUtilsTest.java
@@ -0,0 +1,36 @@
+// Copyright 2014 Google Inc. 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.util;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.devtools.build.lib.packages.TargetUtils;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Test for {@link TargetUtils}
+ */
+@RunWith(JUnit4.class)
+public class TargetUtilsTest {
+
+  @Test
+  public void getRuleLanguage() {
+    assertEquals("java", TargetUtils.getRuleLanguage("java_binary"));
+    assertEquals("foobar", TargetUtils.getRuleLanguage("foobar"));
+    assertEquals("", TargetUtils.getRuleLanguage(""));
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/io/AnsiTerminalPrinterTest.java b/src/test/java/com/google/devtools/build/lib/util/io/AnsiTerminalPrinterTest.java
new file mode 100644
index 0000000..d75208f
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/io/AnsiTerminalPrinterTest.java
@@ -0,0 +1,94 @@
+// Copyright 2014 Google Inc. 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.util.io;
+
+import static com.google.devtools.build.lib.util.io.AnsiTerminalPrinter.Mode;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.devtools.build.lib.testutil.MoreAsserts;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.ByteArrayOutputStream;
+
+/**
+ * A test for {@link AnsiTerminalPrinter}.
+ */
+@RunWith(JUnit4.class)
+public class AnsiTerminalPrinterTest {
+  private ByteArrayOutputStream stream;
+  private AnsiTerminalPrinter printer;
+
+  @Before
+  public void setUp() throws Exception {
+    stream = new ByteArrayOutputStream(1000);
+    printer = new AnsiTerminalPrinter(stream, true);
+  }
+
+  private void setPlainPrinter() {
+    printer = new AnsiTerminalPrinter(stream, false);
+  }
+
+  private void assertString(String string) {
+    assertEquals(string, stream.toString());
+  }
+
+  private void assertRegex(String regex) {
+    MoreAsserts.assertStdoutContainsRegex(regex, stream.toString(), "");
+  }
+
+  @Test
+  public void testPlainPrinter() throws Exception {
+    setPlainPrinter();
+    printer.print("1" + Mode.INFO + "2" + Mode.ERROR + "3" + Mode.WARNING + "4"
+        + Mode.DEFAULT + "5");
+    assertString("12345");
+  }
+
+  @Test
+  public void testDefaultModeIsDefault() throws Exception {
+    printer.print("1" + Mode.DEFAULT + "2");
+    assertString("12");
+  }
+
+  @Test
+  public void testDuplicateMode() throws Exception {
+    printer.print("_A_" + Mode.INFO);
+    printer.print("_B_" + Mode.INFO + "_C_");
+    assertRegex("^_A_.+_B__C_$");
+  }
+
+  @Test
+  public void testModeCodes() throws Exception {
+    printer.print(Mode.INFO + "XXX" + Mode.ERROR + "XXX" + Mode.WARNING +"XXX" + Mode.DEFAULT
+        + "XXX" + Mode.INFO + "XXX" + Mode.ERROR + "XXX" + Mode.WARNING +"XXX" + Mode.DEFAULT);
+    String[] codes = stream.toString().split("XXX");
+    assertEquals(8, codes.length);
+    for (int i = 0; i < 4; i++) {
+      assertTrue(codes[i].length() > 0);
+      assertEquals(codes[i], codes[i+4]);
+    }
+    assertFalse(codes[0].equals(codes[1]));
+    assertFalse(codes[0].equals(codes[2]));
+    assertFalse(codes[0].equals(codes[3]));
+    assertFalse(codes[1].equals(codes[2]));
+    assertFalse(codes[1].equals(codes[3]));
+    assertFalse(codes[2].equals(codes[3]));
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/io/DelegatingOutErrTest.java b/src/test/java/com/google/devtools/build/lib/util/io/DelegatingOutErrTest.java
new file mode 100644
index 0000000..ed644d1
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/io/DelegatingOutErrTest.java
@@ -0,0 +1,72 @@
+// Copyright 2014 Google Inc. 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.util.io;
+
+import static com.google.devtools.build.lib.util.StringUtilities.joinLines;
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * A test for {@link DelegatingOutErr}.
+ */
+@RunWith(JUnit4.class)
+public class DelegatingOutErrTest {
+
+  @Test
+  public void testNewDelegateIsLikeDevNull() {
+    DelegatingOutErr delegate = new DelegatingOutErr();
+    delegate.printOut("Hello, world.\n");
+    delegate.printErr("Feel free to ignore me.\n");
+  }
+
+  @Test
+  public void testSubscribeAndUnsubscribeSink() {
+    DelegatingOutErr delegate = new DelegatingOutErr();
+    delegate.printOut("Nobody will listen to this.\n");
+    RecordingOutErr sink = new RecordingOutErr();
+    delegate.addSink(sink);
+    delegate.printOutLn("Hello, sink.");
+    delegate.removeSink(sink);
+    delegate.printOutLn("... and alone again ...");
+    delegate.addSink(sink);
+    delegate.printOutLn("How are things?");
+    assertEquals("Hello, sink.\nHow are things?\n", sink.outAsLatin1());
+  }
+
+  @Test
+  public void testSubscribeMultipleSinks() {
+    DelegatingOutErr delegate = new DelegatingOutErr();
+    RecordingOutErr left = new RecordingOutErr();
+    RecordingOutErr right = new RecordingOutErr();
+    delegate.addSink(left);
+    delegate.printOutLn("left only");
+    delegate.addSink(right);
+    delegate.printOutLn("both");
+    delegate.removeSink(left);
+    delegate.printOutLn("right only");
+    delegate.removeSink(right);
+    delegate.printOutLn("silence");
+    delegate.addSink(left);
+    delegate.addSink(right);
+    delegate.printOutLn("left and right");
+    assertEquals(joinLines("left only", "both", "left and right", ""),
+                 left.outAsLatin1());
+    assertEquals(joinLines("both", "right only", "left and right", ""),
+                 right.outAsLatin1());
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/io/LinePrefixingOutputStreamTest.java b/src/test/java/com/google/devtools/build/lib/util/io/LinePrefixingOutputStreamTest.java
new file mode 100644
index 0000000..b060beb
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/io/LinePrefixingOutputStreamTest.java
@@ -0,0 +1,88 @@
+// Copyright 2014 Google Inc. 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.util.io;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+/**
+ * Tests {@link LinePrefixingOutputStream}.
+ */
+@RunWith(JUnit4.class)
+public class LinePrefixingOutputStreamTest {
+
+  private byte[] bytes(String string) {
+    return string.getBytes(UTF_8);
+  }
+
+  private String string(byte[] bytes) {
+    return new String(bytes, UTF_8);
+  }
+
+  private ByteArrayOutputStream out = new ByteArrayOutputStream();
+  private LinePrefixingOutputStream prefixOut =
+      new LinePrefixingOutputStream("Prefix: ", out);
+
+  @Test
+  public void testNoOutputUntilNewline() throws IOException {
+    prefixOut.write(bytes("We won't be seeing any output."));
+    assertEquals("", string(out.toByteArray()));
+  }
+
+  @Test
+  public void testOutputIfFlushed() throws IOException {
+    prefixOut.write(bytes("We'll flush after this line."));
+    prefixOut.flush();
+    assertEquals("Prefix: We'll flush after this line.\n",
+                 string(out.toByteArray()));
+  }
+
+  @Test
+  public void testAutoflushUponNewline() throws IOException {
+    prefixOut.write(bytes("Hello, newline.\n"));
+    assertEquals("Prefix: Hello, newline.\n", string(out.toByteArray()));
+  }
+
+  @Test
+  public void testAutoflushUponEmbeddedNewLine() throws IOException {
+    prefixOut.write(bytes("Hello line1.\nHello line2.\nHello line3.\n"));
+    assertEquals(
+        "Prefix: Hello line1.\nPrefix: Hello line2.\nPrefix: Hello line3.\n",
+        string(out.toByteArray()));
+  }
+
+  @Test
+  public void testBufferMaxLengthFlush() throws IOException {
+    String junk = "lots of characters of non-newline junk. ";
+    while (junk.length() < LineFlushingOutputStream.BUFFER_LENGTH) {
+      junk = junk + junk;
+    }
+    junk = junk.substring(0, LineFlushingOutputStream.BUFFER_LENGTH);
+
+    // Also test bug where write on a full buffer blows up
+    prefixOut.write(bytes(junk + junk));
+    prefixOut.write(bytes(junk + junk));
+    prefixOut.write(bytes("x"));
+    assertEquals("Prefix: " + junk + "\n" + "Prefix: " + junk + "\n"
+        + "Prefix: " + junk + "\n" + "Prefix: " + junk + "\n",
+        string(out.toByteArray()));
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/io/OutErrTest.java b/src/test/java/com/google/devtools/build/lib/util/io/OutErrTest.java
new file mode 100644
index 0000000..1419f42
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/io/OutErrTest.java
@@ -0,0 +1,79 @@
+// Copyright 2014 Google Inc. 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.util.io;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.ByteArrayOutputStream;
+
+/**
+ * Tests {@link OutErr}.
+ */
+@RunWith(JUnit4.class)
+public class OutErrTest {
+
+  private ByteArrayOutputStream out = new ByteArrayOutputStream();
+  private ByteArrayOutputStream err = new ByteArrayOutputStream();
+  private OutErr outErr = OutErr.create(out, err);
+
+  @Test
+  public void testRetainsOutErr() {
+    assertSame(out, outErr.getOutputStream());
+    assertSame(err, outErr.getErrorStream());
+  }
+
+  @Test
+  public void testPrintsToOut() {
+    outErr.printOut("Hello, world.");
+    assertEquals("Hello, world.", new String(out.toByteArray()));
+  }
+
+  @Test
+  public void testPrintsToErr() {
+    outErr.printErr("Hello, moon.");
+    assertEquals("Hello, moon.", new String(err.toByteArray()));
+  }
+
+  @Test
+  public void testPrintsToOutWithANewline() {
+    outErr.printOutLn("With a newline.");
+    assertEquals("With a newline.\n", new String(out.toByteArray()));
+  }
+
+  @Test
+  public void testPrintsToErrWithANewline(){
+    outErr.printErrLn("With a newline.");
+    assertEquals("With a newline.\n", new String(err.toByteArray()));
+  }
+
+  @Test
+  public void testPrintsTwoLinesToOut() {
+    outErr.printOutLn("line 1");
+    outErr.printOutLn("line 2");
+    assertEquals("line 1\nline 2\n", new String(out.toByteArray()));
+  }
+
+  @Test
+  public void testPrintsTwoLinesToErr() {
+    outErr.printErrLn("line 1");
+    outErr.printErrLn("line 2");
+    assertEquals("line 1\nline 2\n", new String(err.toByteArray()));
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/io/RecordingOutErrTest.java b/src/test/java/com/google/devtools/build/lib/util/io/RecordingOutErrTest.java
new file mode 100644
index 0000000..b55eb4c
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/io/RecordingOutErrTest.java
@@ -0,0 +1,64 @@
+// Copyright 2014 Google Inc. 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.util.io;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.PrintWriter;
+
+/**
+ * A test for {@link RecordingOutErr}.
+ */
+@RunWith(JUnit4.class)
+public class RecordingOutErrTest {
+
+  protected RecordingOutErr getRecordingOutErr() {
+    return new RecordingOutErr();
+  }
+
+  @Test
+  public void testRecordingOutErrRecords() {
+    RecordingOutErr outErr = getRecordingOutErr();
+
+    outErr.printOut("Test");
+    outErr.printOutLn("out1");
+    PrintWriter writer = new PrintWriter(outErr.getOutputStream());
+    writer.println("Testout2");
+    writer.flush();
+
+    outErr.printErr("Test");
+    outErr.printErrLn("err1");
+    writer = new PrintWriter(outErr.getErrorStream());
+    writer.println("Testerr2");
+    writer.flush();
+
+    assertEquals(outErr.outAsLatin1(), "Testout1\nTestout2\n");
+    assertEquals(outErr.errAsLatin1(), "Testerr1\nTesterr2\n");
+
+    assertTrue(outErr.hasRecordedOutput());
+
+    outErr.reset();
+
+    assertEquals(outErr.outAsLatin1(), "");
+    assertEquals(outErr.errAsLatin1(), "");
+    assertFalse(outErr.hasRecordedOutput());
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/io/StreamDemultiplexerTest.java b/src/test/java/com/google/devtools/build/lib/util/io/StreamDemultiplexerTest.java
new file mode 100644
index 0000000..59277df5
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/io/StreamDemultiplexerTest.java
@@ -0,0 +1,150 @@
+// Copyright 2014 Google Inc. 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.util.io;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+
+import com.google.devtools.build.lib.util.StringUtilities;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.ByteArrayOutputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.util.Random;
+
+/**
+ * Tests {@link StreamDemultiplexer}.
+ */
+@RunWith(JUnit4.class)
+public class StreamDemultiplexerTest {
+
+  private ByteArrayOutputStream out = new ByteArrayOutputStream();
+  private ByteArrayOutputStream err = new ByteArrayOutputStream();
+  private ByteArrayOutputStream ctl = new ByteArrayOutputStream();
+
+  private byte[] lines(String... lines) {
+    try {
+      return StringUtilities.joinLines(lines).getBytes("ISO-8859-1");
+    } catch (UnsupportedEncodingException e) {
+      throw new AssertionError(e);
+    }
+  }
+
+  private String toAnsi(ByteArrayOutputStream stream) {
+    try {
+      return new String(stream.toByteArray(), "ISO-8859-1");
+    } catch (UnsupportedEncodingException e) {
+      throw new AssertionError(e);
+    }
+  }
+
+  private byte[] inAnsi(String string) {
+    try {
+      return string.getBytes("ISO-8859-1");
+    } catch (UnsupportedEncodingException e) {
+      throw new AssertionError(e);
+    }
+  }
+
+  @Test
+  public void testHelloWorldOnStandardOut() throws Exception {
+    byte[] multiplexed = lines("@1@", "Hello, world.");
+    StreamDemultiplexer demux = new StreamDemultiplexer((byte) '1', out);
+    try {
+      demux.write(multiplexed);
+      demux.flush();
+    } finally {
+      demux.close();
+    }
+    assertEquals("Hello, world.", out.toString("ISO-8859-1"));
+  }
+
+  @Test
+  public void testOutErrCtl() throws Exception {
+    byte[] multiplexed = lines("@1@", "out", "@2@", "err", "@3@", "ctl", "");
+    StreamDemultiplexer demux = new StreamDemultiplexer((byte) '1', out, err, ctl);
+    try {
+      demux.write(multiplexed);
+      demux.flush();
+    } finally {
+      demux.close();
+    }
+    assertEquals("out", toAnsi(out));
+    assertEquals("err", toAnsi(err));
+    assertEquals("ctl", toAnsi(ctl));
+  }
+
+  @Test
+  public void testWithoutLineBreaks() throws Exception {
+    byte[] multiplexed = lines("@1@", "just ", "@1@", "one ", "@1@", "line", "");
+    StreamDemultiplexer demux = new StreamDemultiplexer((byte) '1', out);
+    try {
+      demux.write(multiplexed);
+      demux.flush();
+    } finally {
+      demux.close();
+    }
+    assertEquals("just one line", out.toString("ISO-8859-1"));
+  }
+
+  @Test
+  public void testLineBreaks() throws Exception {
+    byte[] multiplexed = lines("@1", "two", "@1", "lines", "");
+    StreamDemultiplexer demux = new StreamDemultiplexer((byte) '1', out);
+    try {
+      demux.write(multiplexed);
+      demux.flush();
+      assertEquals("two\nlines\n", out.toString("ISO-8859-1"));
+    } finally {
+      demux.close();
+    }
+  }
+
+  @Test
+  public void testMultiplexAndBackWithHelloWorld() throws Exception {
+    StreamDemultiplexer demux = new StreamDemultiplexer((byte) '1', out);
+    StreamMultiplexer mux = new StreamMultiplexer(demux);
+    OutputStream out = mux.createStdout();
+    out.write(inAnsi("Hello, world."));
+    out.flush();
+    assertEquals("Hello, world.", toAnsi(this.out));
+  }
+
+  @Test
+  public void testMultiplexDemultiplexBinaryStress() throws Exception {
+    StreamDemultiplexer demux = new StreamDemultiplexer((byte) '1', out, err, ctl);
+    StreamMultiplexer mux = new StreamMultiplexer(demux);
+    OutputStream[] muxOuts = {mux.createStdout(), mux.createStderr(), mux.createControl()};
+    ByteArrayOutputStream[] expectedOuts =
+        {new ByteArrayOutputStream(), new ByteArrayOutputStream(), new ByteArrayOutputStream()};
+
+    Random random = new Random(0xdeadbeef);
+    for (int round = 0; round < 100; round++) {
+      byte[] buffer = new byte[random.nextInt(100)];
+      random.nextBytes(buffer);
+      int streamId = random.nextInt(3);
+      expectedOuts[streamId].write(buffer);
+      expectedOuts[streamId].flush();
+      muxOuts[streamId].write(buffer);
+      muxOuts[streamId].flush();
+    }
+    assertArrayEquals(expectedOuts[0].toByteArray(), out.toByteArray());
+    assertArrayEquals(expectedOuts[1].toByteArray(), err.toByteArray());
+    assertArrayEquals(expectedOuts[2].toByteArray(), ctl.toByteArray());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/io/StreamMultiplexerParallelStressTest.java b/src/test/java/com/google/devtools/build/lib/util/io/StreamMultiplexerParallelStressTest.java
new file mode 100644
index 0000000..db833a5
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/io/StreamMultiplexerParallelStressTest.java
@@ -0,0 +1,128 @@
+// Copyright 2014 Google Inc. 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.util.io;
+
+import com.google.common.io.ByteStreams;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+
+/**
+ * Exercise {@link StreamMultiplexer} in a parallel setting and ensure there's
+ * no corruption.
+ */
+@RunWith(JUnit4.class)
+public class StreamMultiplexerParallelStressTest {
+
+  /**
+   * Characters that could likely cause corruption (they're used as control
+   * characters).
+   */
+  char[] toughCharsToTry = {'\n', '@', '1', '2', '\0', '0'};
+
+  /**
+   * We use a demultiplexer as a simple sanity checker only - that is, we don't
+   * care what the demultiplexer writes, but we are taking advantage of its
+   * built in error checking.
+   */
+  OutputStream devNull  = ByteStreams.nullOutputStream();
+
+  StreamDemultiplexer demux = new StreamDemultiplexer((byte)'1',
+      devNull, devNull, devNull);
+
+  /**
+   * The multiplexer under test.
+   */
+  StreamMultiplexer mux = new StreamMultiplexer(demux);
+
+  /**
+   * Streams is the out / err / control output streams of the multiplexer which
+   * we will write to in parallel.
+   */
+  OutputStream[] streams = {
+      mux.createStdout(), mux.createStderr(), mux.createControl()};
+
+  /**
+   * We will create a bunch of threads that write random data to the streams of
+   * the mux.
+   */
+  class RandomDataPump implements Callable<Object> {
+
+    private Random random;
+
+    public RandomDataPump(int threadId) {
+      random = new Random(threadId * 0xdeadbeefL);
+    }
+
+    @Override
+    public Object call() throws Exception {
+      Thread.yield();
+      OutputStream out = streams[random.nextInt(2)];
+      for (int i = 0; i < 10000; i++) {
+          switch (random.nextInt(5)) {
+          case 0:
+            out.write(random.nextInt());
+            break;
+          case 1:
+            int index = random.nextInt(toughCharsToTry.length);
+            out.write(toughCharsToTry[index]);
+            break;
+          case 2:
+            byte[] buffer = new byte[random.nextInt(312)];
+            random.nextBytes(buffer);
+            out.write(buffer);
+            break;
+          case 3:
+            out.flush();
+            break;
+          case 4:
+            out = streams[random.nextInt(3)];
+            break;
+          }
+      }
+      return null;
+    }
+  }
+
+  @Test
+  public void testSingleThreadedStress() throws Exception {
+    new RandomDataPump(1).call();
+  }
+
+  @Test
+  public void testMultiThreadedStress()
+      throws InterruptedException, ExecutionException {
+    ExecutorService service = Executors.newFixedThreadPool(50);
+
+    List<Future<?>> futures = new ArrayList<>();
+    for (int threadId = 0; threadId < 50; threadId++) {
+      futures.add(service.submit(new RandomDataPump(threadId)));
+    }
+    for (Future<?> future : futures) {
+      future.get();
+    }
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/util/io/StreamMultiplexerTest.java b/src/test/java/com/google/devtools/build/lib/util/io/StreamMultiplexerTest.java
new file mode 100644
index 0000000..aef6da3
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/util/io/StreamMultiplexerTest.java
@@ -0,0 +1,149 @@
+// Copyright 2014 Google Inc. 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.util.io;
+
+import static com.google.devtools.build.lib.util.StringUtilities.joinLines;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+
+import com.google.common.io.ByteStreams;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+
+/**
+ * Test for {@link StreamMultiplexer}.
+ */
+@RunWith(JUnit4.class)
+public class StreamMultiplexerTest {
+
+  private ByteArrayOutputStream multiplexed;
+  private OutputStream out;
+  private OutputStream err;
+  private OutputStream ctl;
+
+  @Before
+  public void setUp() throws Exception {
+    multiplexed = new ByteArrayOutputStream();
+    StreamMultiplexer multiplexer = new StreamMultiplexer(multiplexed);
+    out = multiplexer.createStdout();
+    err = multiplexer.createStderr();
+    ctl = multiplexer.createControl();
+  }
+
+  @Test
+  public void testEmptyWire() throws IOException {
+    out.flush();
+    err.flush();
+    ctl.flush();
+    assertEquals(0, multiplexed.toByteArray().length);
+  }
+
+  private static byte[] getLatin(String string)
+      throws UnsupportedEncodingException {
+    return string.getBytes("ISO-8859-1");
+  }
+
+  private static String getLatin(byte[] bytes)
+      throws UnsupportedEncodingException {
+    return new String(bytes, "ISO-8859-1");
+  }
+
+  @Test
+  public void testHelloWorldOnStdOut() throws IOException {
+    out.write(getLatin("Hello, world."));
+    out.flush();
+    assertEquals(joinLines("@1@", "Hello, world.", ""),
+                 getLatin(multiplexed.toByteArray()));
+  }
+
+  @Test
+  public void testInterleavedStdoutStderrControl() throws Exception {
+    out.write(getLatin("Hello, stdout."));
+    out.flush();
+    err.write(getLatin("Hello, stderr."));
+    err.flush();
+    ctl.write(getLatin("Hello, control."));
+    ctl.flush();
+    out.write(getLatin("... and back!"));
+    out.flush();
+    assertEquals(joinLines("@1@", "Hello, stdout.",
+                           "@2@", "Hello, stderr.",
+                           "@3@", "Hello, control.",
+                           "@1@", "... and back!",
+                           ""),
+                 getLatin(multiplexed.toByteArray()));
+  }
+
+  @Test
+  public void testWillNotCommitToUnderlyingStreamUnlessFlushOrNewline()
+      throws Exception {
+    out.write(getLatin("There are no newline characters in here, so it won't" +
+                       " get written just yet."));
+    assertArrayEquals(multiplexed.toByteArray(), new byte[0]);
+  }
+
+  @Test
+  public void testNewlineTriggersFlush() throws Exception {
+    out.write(getLatin("No newline just yet, so no flushing. "));
+    assertArrayEquals(multiplexed.toByteArray(), new byte[0]);
+    out.write(getLatin("OK, here we go:\nAnd more to come."));
+
+    String expected = joinLines("@1",
+        "No newline just yet, so no flushing. OK, here we go:", "");
+
+    assertEquals(expected, getLatin(multiplexed.toByteArray()));
+
+    out.write((byte) '\n');
+    expected += joinLines("@1", "And more to come.", "");
+
+    assertEquals(expected, getLatin(multiplexed.toByteArray()));
+  }
+
+  @Test
+  public void testFlush() throws Exception {
+    out.write(getLatin("Don't forget to flush!"));
+    assertArrayEquals(new byte[0], multiplexed.toByteArray());
+    out.flush(); // now the output will appear in multiplexed.
+    assertEquals(joinLines("@1@", "Don't forget to flush!", ""),
+        getLatin(multiplexed.toByteArray()));
+  }
+
+  @Test
+  public void testByteEncoding() throws IOException {
+    OutputStream devNull = ByteStreams.nullOutputStream();
+    StreamDemultiplexer demux = new StreamDemultiplexer((byte) '1', devNull);
+    StreamMultiplexer mux = new StreamMultiplexer(demux);
+    OutputStream out = mux.createStdout();
+
+    // When we cast 266 to a byte, we get 10. So basically, we ended up
+    // comparing 266 with 10 as an integer (because out.write takes an int),
+    // and then later cast it to 10. This way we'd end up with a control
+    // character \n in the middle of the payload which would then screw things
+    // up when the real control character arrived. The fixed version of the
+    // StreamMultiplexer avoids this problem by always casting to a byte before
+    // carrying out any comparisons.
+
+    out.write(266);
+    out.write(10);
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/FileSystemConcurrencyTest.java b/src/test/java/com/google/devtools/build/lib/vfs/FileSystemConcurrencyTest.java
new file mode 100644
index 0000000..c90de18
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/FileSystemConcurrencyTest.java
@@ -0,0 +1,97 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import com.google.devtools.build.lib.testutil.TestUtils;
+import com.google.devtools.build.lib.vfs.util.FileSystems;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * (Slow) tests of FileSystem under concurrency.
+ *
+ * These tests are nondeterministic but provide good coverage nonetheless.
+ */
+@RunWith(JUnit4.class)
+public class FileSystemConcurrencyTest {
+
+  Path workingDir;
+
+  @Before
+  public void setUp() throws Exception {
+    FileSystem testFS = FileSystems.initDefaultAsNative();
+
+    // Resolve symbolic links in the temp dir:
+    workingDir = testFS.getPath(new File(TestUtils.tmpDir()).getCanonicalPath());
+  }
+
+  @Test
+  public void testConcurrentSymlinkModifications() throws Exception {
+    final Path xFile = workingDir.getRelative("file");
+    FileSystemUtils.createEmptyFile(xFile);
+
+    final Path xLinkToFile = workingDir.getRelative("link");
+
+    // "Boxed" for pass-by-reference.
+    final boolean[] run = { true };
+    final IOException[] exception = { null };
+    Thread createThread = new Thread() {
+      @Override
+      public void run() {
+        while (run[0]) {
+          if (!xLinkToFile.exists()) {
+            try {
+              xLinkToFile.createSymbolicLink(xFile);
+            } catch (IOException e) {
+              exception[0] = e;
+              return;
+            }
+          }
+        }
+      }
+    };
+    Thread deleteThread = new Thread() {
+      @Override
+      public void run() {
+        while (run[0]) {
+          if (xLinkToFile.exists(Symlinks.NOFOLLOW)) {
+            try {
+              xLinkToFile.delete();
+            } catch (IOException e) {
+              exception[0] = e;
+              return;
+            }
+          }
+        }
+      }
+    };
+    createThread.start();
+    deleteThread.start();
+    Thread.sleep(1000);
+    run[0] = false;
+    createThread.join(0);
+    deleteThread.join(0);
+
+    if (exception[0] != null) {
+      throw exception[0];
+    }
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/FileSystemTest.java b/src/test/java/com/google/devtools/build/lib/vfs/FileSystemTest.java
new file mode 100644
index 0000000..b6e88d8
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/FileSystemTest.java
@@ -0,0 +1,1356 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.base.Preconditions;
+import com.google.common.io.BaseEncoding;
+import com.google.devtools.build.lib.testutil.MoreAsserts;
+import com.google.devtools.build.lib.testutil.TestUtils;
+import com.google.devtools.build.lib.util.Fingerprint;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * This class handles the generic tests that any filesystem must pass.
+ *
+ * <p>Each filesystem-test should inherit from this class, thereby obtaining
+ * all the tests.
+ */
+public abstract class FileSystemTest {
+
+  private long savedTime;
+  protected FileSystem testFS;
+  protected boolean supportsSymlinks;
+  protected Path workingDir;
+
+  // Some useful examples of various kinds of files (mnemonic: "x" = "eXample")
+  protected Path xNothing;
+  protected Path xFile;
+  protected Path xNonEmptyDirectory;
+  protected Path xNonEmptyDirectoryFoo;
+  protected Path xEmptyDirectory;
+
+  @Before
+  public void setUp() throws Exception {
+    testFS = getFreshFileSystem();
+    workingDir = testFS.getPath(getTestTmpDir());
+    cleanUpWorkingDirectory(workingDir);
+    supportsSymlinks = testFS.supportsSymbolicLinks();
+
+    // % ls -lR
+    // -rw-rw-r-- xFile
+    // drwxrwxr-x xNonEmptyDirectory
+    // -rw-rw-r-- xNonEmptyDirectory/foo
+    // drwxrwxr-x xEmptyDirectory
+
+    xNothing = absolutize("xNothing");
+    xFile = absolutize("xFile");
+    xNonEmptyDirectory = absolutize("xNonEmptyDirectory");
+    xNonEmptyDirectoryFoo = xNonEmptyDirectory.getChild("foo");
+    xEmptyDirectory = absolutize("xEmptyDirectory");
+
+    FileSystemUtils.createEmptyFile(xFile);
+    xNonEmptyDirectory.createDirectory();
+    FileSystemUtils.createEmptyFile(xNonEmptyDirectoryFoo);
+    xEmptyDirectory.createDirectory();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    destroyFileSystem(testFS);
+  }
+
+  /**
+   * Returns an instance of the file system to test.
+   */
+  protected abstract FileSystem getFreshFileSystem() throws IOException;
+
+  protected boolean isSymbolicLink(File file) {
+    return com.google.devtools.build.lib.unix.FilesystemUtils.isSymbolicLink(file);
+  }
+
+  protected void setWritable(File file) throws IOException {
+    com.google.devtools.build.lib.unix.FilesystemUtils.setWritable(file);
+  }
+
+  protected void setExecutable(File file) throws IOException {
+    com.google.devtools.build.lib.unix.FilesystemUtils.setExecutable(file);
+  }
+
+  private static final Pattern STAT_SUBDIR_ERROR = Pattern.compile("(.*) \\(Not a directory\\)");
+
+  // Test that file is not present, using statIfFound. Base implementation throws an exception, but
+  // subclasses may override statIfFound to return null, in which case their tests should override
+  // this method.
+  @SuppressWarnings("unused") // Subclasses may throw.
+  protected void expectNotFound(Path path) throws IOException {
+    try {
+      assertNull(path.statIfFound());
+    } catch (IOException e) {
+      // May be because of a non-directory path component. Parse exception to check this.
+      Matcher matcher = STAT_SUBDIR_ERROR.matcher(e.getMessage());
+      if (!matcher.matches() || !path.getPathString().startsWith(matcher.group(1))) {
+        // Throw if this doesn't match what an ENOTDIR error looks like.
+        throw e;
+      }
+    }
+  }
+
+  /**
+   * Removes all stuff from the test filesystem.
+   */
+  protected void destroyFileSystem(FileSystem fileSystem) throws IOException {
+    Preconditions.checkArgument(fileSystem.equals(workingDir.getFileSystem()));
+    cleanUpWorkingDirectory(workingDir);
+  }
+
+  /**
+   * Cleans up the working directory by removing everything.
+   */
+  protected void cleanUpWorkingDirectory(Path workingPath)
+      throws IOException {
+    if (workingPath.exists()) {
+      removeEntireDirectory(workingPath.getPathFile()); // uses java.io.File!
+    }
+    FileSystemUtils.createDirectoryAndParents(workingPath);
+  }
+
+  /**
+   * This function removes an entire directory and all of its contents.
+   * Much like rm -rf directoryToRemove
+   */
+  protected void removeEntireDirectory(File directoryToRemove)
+      throws IOException {
+    // make sure that we do not remove anything outside the test directory
+    Path testDirPath = testFS.getPath(getTestTmpDir());
+    if (!testFS.getPath(directoryToRemove.getAbsolutePath()).startsWith(testDirPath)) {
+      throw new IOException("trying to remove files outside of the testdata directory");
+    }
+    // Some tests set the directories read-only and/or non-executable, so
+    // override that:
+    setWritable(directoryToRemove);
+    setExecutable(directoryToRemove);
+
+    File[] files = directoryToRemove.listFiles();
+    if (files != null) {
+      for (File currentFile : files) {
+        boolean isSymbolicLink = isSymbolicLink(currentFile);
+        if (!isSymbolicLink && currentFile.isDirectory()) {
+          removeEntireDirectory(currentFile);
+        } else {
+          if (!isSymbolicLink) {
+            setWritable(currentFile);
+          }
+          if (!currentFile.delete()) {
+            throw new IOException("Failed to delete '" + currentFile + "'");
+          }
+        }
+      }
+    }
+    if (!directoryToRemove.delete()) {
+      throw new IOException("Failed to delete '" + directoryToRemove + "'");
+    }
+  }
+
+  /**
+   * Returns the directory to use as the FileSystem's working directory.
+   * Canonicalized to make tests hermetic against symbolic links in TEST_TMPDIR.
+   */
+  protected final String getTestTmpDir() throws IOException {
+    return new File(TestUtils.tmpDir()).getCanonicalPath() + "/testdir";
+  }
+
+  /**
+   * Indirection to create links so we can test FileSystems that do not support
+   * link creation.  For example, JavaFileSystemTest overrides this method
+   * and creates the link with an alternate FileSystem.
+   */
+  protected void createSymbolicLink(Path link, Path target) throws IOException {
+    createSymbolicLink(link, target.asFragment());
+  }
+
+  /**
+   * Indirection to create links so we can test FileSystems that do not support
+   * link creation.  For example, JavaFileSystemTest overrides this method
+   * and creates the link with an alternate FileSystem.
+   */
+  protected void createSymbolicLink(Path link, PathFragment target) throws IOException {
+    link.createSymbolicLink(target);
+  }
+
+  /**
+   * Indirection to setReadOnly(false) on FileSystems that do not
+   * support setReadOnly(false).  For example, JavaFileSystemTest overrides this
+   * method and makes the Path writable with an alternate FileSystem.
+   */
+  protected void makeWritable(Path target) throws IOException {
+    target.setWritable(true);
+  }
+
+  /**
+   * Indirection to {@link Path#setExecutable(boolean)} on FileSystems that do
+   * not support setExecutable.  For example, JavaFileSystemTest overrides this
+   * method and makes the Path executable with an alternate FileSystem.
+   */
+  protected void setExecutable(Path target, boolean mode) throws IOException {
+    target.setExecutable(mode);
+  }
+
+  // TODO(bazel-team): (2011) Put in a setLastModifiedTime into the various objects
+  // and clobber the current time of the object we're currently handling.
+  // Otherwise testing the thing might get a little hard, depending on the clock.
+  void storeReferenceTime(long timeToMark) {
+    savedTime = timeToMark;
+  }
+
+  boolean isLaterThanreferenceTime(long testTime) {
+    return (savedTime <= testTime);
+  }
+
+  Path getTestFile() throws IOException {
+    Path tempPath = absolutize("test-file");
+    FileSystemUtils.createEmptyFile(tempPath);
+    return tempPath;
+  }
+
+  protected Path absolutize(String relativePathName) {
+    return workingDir.getRelative(relativePathName);
+  }
+
+  // Here the tests begin.
+
+  @Test
+  public void testIsFileForNonexistingPath() {
+    Path nonExistingPath = testFS.getPath("/something/strange");
+    assertFalse(nonExistingPath.isFile());
+  }
+
+  @Test
+  public void testIsDirectoryForNonexistingPath() {
+    Path nonExistingPath = testFS.getPath("/something/strange");
+    assertFalse(nonExistingPath.isDirectory());
+  }
+
+  @Test
+  public void testIsLinkForNonexistingPath() {
+    Path nonExistingPath = testFS.getPath("/something/strange");
+    assertFalse(nonExistingPath.isSymbolicLink());
+  }
+
+  @Test
+  public void testExistsForNonexistingPath() throws Exception {
+    Path nonExistingPath = testFS.getPath("/something/strange");
+    assertFalse(nonExistingPath.exists());
+    expectNotFound(nonExistingPath);
+  }
+
+  @Test
+  public void testBadPermissionsThrowsExceptionOnStatIfFound() throws Exception {
+    Path inaccessible = absolutize("inaccessible");
+    inaccessible.createDirectory();
+    Path child = inaccessible.getChild("child");
+    FileSystemUtils.createEmptyFile(child);
+    inaccessible.setExecutable(false);
+    assertFalse(child.exists());
+    try {
+      child.statIfFound();
+      fail();
+    } catch (IOException expected) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void testStatIfFoundReturnsNullForChildOfNonDir() throws Exception {
+    Path foo = absolutize("foo");
+    foo.createDirectory();
+    Path nonDir = foo.getRelative("bar");
+    FileSystemUtils.createEmptyFile(nonDir);
+    assertNull(nonDir.getRelative("file").statIfFound());
+  }
+
+  // The following tests check the handling of the current working directory.
+  @Test
+  public void testCreatePathRelativeToWorkingDirectory() {
+    Path relativeCreatedPath = absolutize("some-file");
+    Path expectedResult = workingDir.getRelative(new PathFragment("some-file"));
+
+    assertEquals(expectedResult, relativeCreatedPath);
+  }
+
+  // The following tests check the handling of the root directory
+  @Test
+  public void testRootIsDirectory() {
+    Path rootPath = testFS.getPath("/");
+    assertTrue(rootPath.isDirectory());
+  }
+
+  @Test
+  public void testRootHasNoParent() {
+    Path rootPath = testFS.getPath("/");
+    assertNull(rootPath.getParentDirectory());
+  }
+
+  // The following functions test the creation of files/links/directories.
+  @Test
+  public void testFileExists() throws Exception {
+    Path someFile = absolutize("some-file");
+    FileSystemUtils.createEmptyFile(someFile);
+    assertTrue(someFile.exists());
+    assertNotNull(someFile.statIfFound());
+  }
+
+  @Test
+  public void testFileIsFile() throws Exception {
+    Path someFile = absolutize("some-file");
+    FileSystemUtils.createEmptyFile(someFile);
+    assertTrue(someFile.isFile());
+  }
+
+  @Test
+  public void testFileIsNotDirectory() throws Exception {
+    Path someFile = absolutize("some-file");
+    FileSystemUtils.createEmptyFile(someFile);
+    assertFalse(someFile.isDirectory());
+  }
+
+  @Test
+  public void testFileIsNotSymbolicLink() throws Exception {
+    Path someFile = absolutize("some-file");
+    FileSystemUtils.createEmptyFile(someFile);
+    assertFalse(someFile.isSymbolicLink());
+  }
+
+  @Test
+  public void testDirectoryExists() throws Exception {
+    Path someDirectory = absolutize("some-dir");
+    someDirectory.createDirectory();
+    assertTrue(someDirectory.exists());
+    assertNotNull(someDirectory.statIfFound());
+  }
+
+  @Test
+  public void testDirectoryIsDirectory() throws Exception {
+    Path someDirectory = absolutize("some-dir");
+    someDirectory.createDirectory();
+    assertTrue(someDirectory.isDirectory());
+  }
+
+  @Test
+  public void testDirectoryIsNotFile() throws Exception {
+    Path someDirectory = absolutize("some-dir");
+    someDirectory.createDirectory();
+    assertFalse(someDirectory.isFile());
+  }
+
+  @Test
+  public void testDirectoryIsNotSymbolicLink() throws Exception {
+    Path someDirectory = absolutize("some-dir");
+    someDirectory.createDirectory();
+    assertFalse(someDirectory.isSymbolicLink());
+  }
+
+  @Test
+  public void testSymbolicFileLinkExists() throws Exception {
+    if (supportsSymlinks) {
+      Path someLink = absolutize("some-link");
+      someLink.createSymbolicLink(xFile);
+      assertTrue(someLink.exists());
+      assertNotNull(someLink.statIfFound());
+    }
+  }
+
+  @Test
+  public void testSymbolicFileLinkIsSymbolicLink() throws Exception {
+    if (supportsSymlinks) {
+      Path someLink = absolutize("some-link");
+      someLink.createSymbolicLink(xFile);
+      assertTrue(someLink.isSymbolicLink());
+    }
+  }
+
+  @Test
+  public void testSymbolicFileLinkIsFile() throws Exception {
+    if (supportsSymlinks) {
+      Path someLink = absolutize("some-link");
+      someLink.createSymbolicLink(xFile);
+      assertTrue(someLink.isFile());
+    }
+  }
+
+  @Test
+  public void testSymbolicFileLinkIsNotDirectory() throws Exception {
+    if (supportsSymlinks) {
+      Path someLink = absolutize("some-link");
+      someLink.createSymbolicLink(xFile);
+      assertFalse(someLink.isDirectory());
+    }
+  }
+
+  @Test
+  public void testSymbolicDirLinkExists() throws Exception {
+    if (supportsSymlinks) {
+      Path someLink = absolutize("some-link");
+      someLink.createSymbolicLink(xEmptyDirectory);
+      assertTrue(someLink.exists());
+      assertNotNull(someLink.statIfFound());
+    }
+  }
+
+  @Test
+  public void testSymbolicDirLinkIsSymbolicLink() throws Exception {
+    if (supportsSymlinks) {
+      Path someLink = absolutize("some-link");
+      someLink.createSymbolicLink(xEmptyDirectory);
+      assertTrue(someLink.isSymbolicLink());
+    }
+  }
+
+  @Test
+  public void testSymbolicDirLinkIsDirectory() throws Exception {
+    if (supportsSymlinks) {
+      Path someLink = absolutize("some-link");
+      someLink.createSymbolicLink(xEmptyDirectory);
+      assertTrue(someLink.isDirectory());
+    }
+  }
+
+  @Test
+  public void testSymbolicDirLinkIsNotFile() throws Exception {
+    if (supportsSymlinks) {
+      Path someLink = absolutize("some-link");
+      someLink.createSymbolicLink(xEmptyDirectory);
+      assertFalse(someLink.isFile());
+    }
+  }
+
+  @Test
+  public void testChildOfNonDirectory() throws Exception {
+    Path somePath = absolutize("file-name");
+    FileSystemUtils.createEmptyFile(somePath);
+    Path childOfNonDir = somePath.getChild("child");
+    assertFalse(childOfNonDir.exists());
+    expectNotFound(childOfNonDir);
+  }
+
+  @Test
+  public void testCreateDirectoryIsEmpty() throws Exception {
+    Path newPath = xEmptyDirectory.getChild("new-dir");
+    newPath.createDirectory();
+    assertEquals(newPath.getDirectoryEntries().size(), 0);
+  }
+
+  @Test
+  public void testCreateDirectoryIsOnlyChildInParent() throws Exception {
+    Path newPath = xEmptyDirectory.getChild("new-dir");
+    newPath.createDirectory();
+    assertEquals(1, newPath.getParentDirectory().getDirectoryEntries().size());
+    assertThat(newPath.getParentDirectory().getDirectoryEntries()).containsExactly(newPath);
+  }
+
+  @Test
+  public void testCreateDirectories() throws Exception {
+    Path newPath = absolutize("new-dir/sub/directory");
+    assertTrue(FileSystemUtils.createDirectoryAndParents(newPath));
+  }
+
+  @Test
+  public void testCreateDirectoriesIsDirectory() throws Exception {
+    Path newPath = absolutize("new-dir/sub/directory");
+    FileSystemUtils.createDirectoryAndParents(newPath);
+    assertTrue(newPath.isDirectory());
+  }
+
+  @Test
+  public void testCreateDirectoriesIsNotFile() throws Exception {
+    Path newPath = absolutize("new-dir/sub/directory");
+    FileSystemUtils.createDirectoryAndParents(newPath);
+    assertFalse(newPath.isFile());
+  }
+
+  @Test
+  public void testCreateDirectoriesIsNotSymbolicLink() throws Exception {
+    Path newPath = absolutize("new-dir/sub/directory");
+    FileSystemUtils.createDirectoryAndParents(newPath);
+    assertFalse(newPath.isSymbolicLink());
+  }
+
+  @Test
+  public void testCreateDirectoriesIsEmpty() throws Exception {
+    Path newPath = absolutize("new-dir/sub/directory");
+    FileSystemUtils.createDirectoryAndParents(newPath);
+    assertEquals(newPath.getDirectoryEntries().size(), 0);
+  }
+
+  @Test
+  public void testCreateDirectoriesIsOnlyChildInParent() throws Exception {
+    Path newPath = absolutize("new-dir/sub/directory");
+    FileSystemUtils.createDirectoryAndParents(newPath);
+    assertEquals(1, newPath.getParentDirectory().getDirectoryEntries().size());
+    assertThat(newPath.getParentDirectory().getDirectoryEntries()).containsExactly(newPath);
+  }
+
+  @Test
+  public void testCreateEmptyFileIsEmpty() throws Exception {
+    Path newPath = xEmptyDirectory.getChild("new-file");
+    FileSystemUtils.createEmptyFile(newPath);
+
+    assertEquals(newPath.getFileSize(), 0);
+  }
+
+  @Test
+  public void testCreateFileIsOnlyChildInParent() throws Exception {
+    Path newPath = xEmptyDirectory.getChild("new-file");
+    FileSystemUtils.createEmptyFile(newPath);
+    assertEquals(1, newPath.getParentDirectory().getDirectoryEntries().size());
+    assertThat(newPath.getParentDirectory().getDirectoryEntries()).containsExactly(newPath);
+  }
+
+  // The following functions test the behavior if errors occur during the
+  // creation of files/links/directories.
+  @Test
+  public void testCreateDirectoryWhereDirectoryAlreadyExists() throws Exception {
+    assertFalse(xEmptyDirectory.createDirectory());
+  }
+
+  @Test
+  public void testCreateDirectoryWhereFileAlreadyExists() {
+    try {
+      xFile.createDirectory();
+      fail();
+    } catch (IOException e) {
+      assertEquals(xFile + " (File exists)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testCannotCreateDirectoryWithoutExistingParent() throws Exception {
+    Path newPath = testFS.getPath("/deep/new-dir");
+    try {
+      newPath.createDirectory();
+      fail();
+    } catch (FileNotFoundException e) {
+      MoreAsserts.assertEndsWith(" (No such file or directory)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testCannotCreateDirectoryWithReadOnlyParent() throws Exception {
+    xEmptyDirectory.setWritable(false);
+    Path xChildOfReadonlyDir = xEmptyDirectory.getChild("x");
+    try {
+      xChildOfReadonlyDir.createDirectory();
+      fail();
+    } catch (IOException e) {
+      assertEquals(xChildOfReadonlyDir + " (Permission denied)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testCannotCreateFileWithoutExistingParent() throws Exception {
+    Path newPath = testFS.getPath("/non-existing-dir/new-file");
+    try {
+      FileSystemUtils.createEmptyFile(newPath);
+      fail();
+    } catch (FileNotFoundException e) {
+      MoreAsserts.assertEndsWith(" (No such file or directory)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testCannotCreateFileWithReadOnlyParent() throws Exception {
+    xEmptyDirectory.setWritable(false);
+    Path xChildOfReadonlyDir = xEmptyDirectory.getChild("x");
+    try {
+      FileSystemUtils.createEmptyFile(xChildOfReadonlyDir);
+      fail();
+    } catch (IOException e) {
+      assertEquals(xChildOfReadonlyDir + " (Permission denied)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testCannotCreateFileWithinFile() throws Exception {
+    Path newFilePath = absolutize("some-file");
+    FileSystemUtils.createEmptyFile(newFilePath);
+    Path wrongPath = absolutize("some-file/new-file");
+    try {
+      FileSystemUtils.createEmptyFile(wrongPath);
+      fail();
+    } catch (IOException e) {
+      MoreAsserts.assertEndsWith(" (Not a directory)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testCannotCreateDirectoryWithinFile() throws Exception {
+    Path newFilePath = absolutize("some-file");
+    FileSystemUtils.createEmptyFile(newFilePath);
+    Path wrongPath = absolutize("some-file/new-file");
+    try {
+      wrongPath.createDirectory();
+      fail();
+    } catch (IOException e) {
+      MoreAsserts.assertEndsWith(" (Not a directory)", e.getMessage());
+    }
+  }
+
+  // Test directory contents
+  @Test
+  public void testCreateMultipleChildren() throws Exception {
+    Path theDirectory = absolutize("foo/");
+    theDirectory.createDirectory();
+    Path newPath1 = absolutize("foo/new-file-1");
+    Path newPath2 = absolutize("foo/new-file-2");
+    Path newPath3 = absolutize("foo/new-file-3");
+
+    FileSystemUtils.createEmptyFile(newPath1);
+    FileSystemUtils.createEmptyFile(newPath2);
+    FileSystemUtils.createEmptyFile(newPath3);
+
+    assertThat(theDirectory.getDirectoryEntries()).containsExactly(newPath1, newPath2, newPath3);
+  }
+
+  @Test
+  public void testGetDirectoryEntriesThrowsExceptionWhenRunOnFile() throws Exception {
+    try {
+      xFile.getDirectoryEntries();
+      fail("No Exception thrown.");
+    } catch (IOException ex) {
+      if (ex instanceof FileNotFoundException) {
+        fail("The method should throw an object of class IOException.");
+      }
+      assertEquals(xFile + " (Not a directory)", ex.getMessage());
+    }
+  }
+
+  @Test
+  public void testGetDirectoryEntriesThrowsExceptionForNonexistingPath() {
+    Path somePath = testFS.getPath("/non-existing-path");
+    try {
+      somePath.getDirectoryEntries();
+      fail("FileNotFoundException not thrown.");
+    } catch (Exception x) {
+      assertEquals(somePath + " (No such file or directory)", x.getMessage());
+    }
+  }
+
+  // Test the removal of items
+  @Test
+  public void testDeleteDirectory() throws Exception {
+    assertTrue(xEmptyDirectory.delete());
+  }
+
+  @Test
+  public void testDeleteDirectoryIsNotDirectory() throws Exception {
+    xEmptyDirectory.delete();
+    assertFalse(xEmptyDirectory.isDirectory());
+  }
+
+  @Test
+  public void testDeleteDirectoryParentSize() throws Exception {
+    int parentSize = workingDir.getDirectoryEntries().size();
+    xEmptyDirectory.delete();
+    assertEquals(workingDir.getDirectoryEntries().size(), parentSize - 1);
+  }
+
+  @Test
+  public void testDeleteFile() throws Exception {
+    assertTrue(xFile.delete());
+  }
+
+  @Test
+  public void testDeleteFileIsNotFile() throws Exception {
+    xFile.delete();
+    assertFalse(xEmptyDirectory.isFile());
+  }
+
+  @Test
+  public void testDeleteFileParentSize() throws Exception {
+    int parentSize = workingDir.getDirectoryEntries().size();
+    xFile.delete();
+    assertEquals(workingDir.getDirectoryEntries().size(), parentSize - 1);
+  }
+
+  @Test
+  public void testDeleteRemovesCorrectFile() throws Exception {
+    Path newPath1 = xEmptyDirectory.getChild("new-file-1");
+    Path newPath2 = xEmptyDirectory.getChild("new-file-2");
+    Path newPath3 = xEmptyDirectory.getChild("new-file-3");
+
+    FileSystemUtils.createEmptyFile(newPath1);
+    FileSystemUtils.createEmptyFile(newPath2);
+    FileSystemUtils.createEmptyFile(newPath3);
+
+    assertTrue(newPath2.delete());
+    assertThat(xEmptyDirectory.getDirectoryEntries()).containsExactly(newPath1, newPath3);
+  }
+
+  @Test
+  public void testDeleteNonExistingDir() throws Exception {
+    Path path = xEmptyDirectory.getRelative("non-existing-dir");
+    assertFalse(path.delete());
+  }
+
+  @Test
+  public void testDeleteNotADirectoryPath() throws Exception {
+    Path path = xFile.getChild("new-file");
+    assertFalse(path.delete());
+  }
+
+  // Here we test the situations where delete should throw exceptions.
+  @Test
+  public void testDeleteNonEmptyDirectoryThrowsException() throws Exception {
+    try {
+      xNonEmptyDirectory.delete();
+      fail();
+    } catch (IOException e) {
+      assertEquals(xNonEmptyDirectory + " (Directory not empty)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testDeleteNonEmptyDirectoryNotDeletedDirectory() throws Exception {
+    try {
+      xNonEmptyDirectory.delete();
+      fail();
+    } catch (IOException e) {
+      // Expected
+    }
+
+    assertTrue(xNonEmptyDirectory.isDirectory());
+  }
+
+  @Test
+  public void testDeleteNonEmptyDirectoryNotDeletedFile() throws Exception {
+    try {
+      xNonEmptyDirectory.delete();
+      fail();
+    } catch (IOException e) {
+      // Expected
+    }
+
+    assertTrue(xNonEmptyDirectoryFoo.isFile());
+  }
+
+  @Test
+  public void testCannotRemoveRoot() {
+    Path rootDirectory = testFS.getRootDirectory();
+    try {
+      rootDirectory.delete();
+      fail();
+    } catch (IOException e) {
+      String msg = e.getMessage();
+      assertTrue(String.format("got %s want EBUSY or ENOTEMPTY", msg),
+          msg.endsWith(" (Directory not empty)")
+          || msg.endsWith(" (Device or resource busy)")
+          || msg.endsWith(" (Is a directory)"));  // Happens on OS X.
+    }
+  }
+
+  // Test the date functions
+  @Test
+  public void testCreateFileChangesTimeOfDirectory() throws Exception {
+    storeReferenceTime(workingDir.getLastModifiedTime());
+    Path newPath = absolutize("new-file");
+    FileSystemUtils.createEmptyFile(newPath);
+    assertTrue(isLaterThanreferenceTime(workingDir.getLastModifiedTime()));
+  }
+
+  @Test
+  public void testRemoveFileChangesTimeOfDirectory() throws Exception {
+    Path newPath = absolutize("new-file");
+    FileSystemUtils.createEmptyFile(newPath);
+    storeReferenceTime(workingDir.getLastModifiedTime());
+    newPath.delete();
+    assertTrue(isLaterThanreferenceTime(workingDir.getLastModifiedTime()));
+  }
+
+  // This test is a little bit strange, as we cannot test the progression
+  // of the time directly. As the Java time and the OS time are slightly different.
+  // Therefore, we first create an unrelated file to get a notion
+  // of the current OS time and use that as a baseline.
+  @Test
+  public void testCreateFileTimestamp() throws Exception {
+    Path syncFile = absolutize("sync-file");
+    FileSystemUtils.createEmptyFile(syncFile);
+
+    Path newFile = absolutize("new-file");
+    storeReferenceTime(syncFile.getLastModifiedTime());
+    FileSystemUtils.createEmptyFile(newFile);
+    assertTrue(isLaterThanreferenceTime(newFile.getLastModifiedTime()));
+  }
+
+  @Test
+  public void testCreateDirectoryTimestamp() throws Exception {
+    Path syncFile = absolutize("sync-file");
+    FileSystemUtils.createEmptyFile(syncFile);
+
+    Path newPath = absolutize("new-dir");
+    storeReferenceTime(syncFile.getLastModifiedTime());
+    assertTrue(newPath.createDirectory());
+    assertTrue(isLaterThanreferenceTime(newPath.getLastModifiedTime()));
+  }
+
+  @Test
+  public void testWriteChangesModifiedTime() throws Exception {
+    storeReferenceTime(xFile.getLastModifiedTime());
+    FileSystemUtils.writeContentAsLatin1(xFile, "abc19");
+    assertTrue(isLaterThanreferenceTime(xFile.getLastModifiedTime()));
+  }
+
+  @Test
+  public void testGetLastModifiedTimeThrowsExceptionForNonexistingPath() throws Exception {
+    Path newPath = testFS.getPath("/non-existing-dir");
+    try {
+      newPath.getLastModifiedTime();
+      fail("FileNotFoundException not thrown!");
+    } catch (FileNotFoundException x) {
+      assertEquals(newPath + " (No such file or directory)", x.getMessage());
+    }
+  }
+
+  // Test file size
+  @Test
+  public void testFileSizeThrowsExceptionForNonexistingPath() throws Exception {
+    Path newPath = testFS.getPath("/non-existing-file");
+    try {
+      newPath.getFileSize();
+      fail("FileNotFoundException not thrown.");
+    } catch (FileNotFoundException e) {
+      assertEquals(newPath + " (No such file or directory)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testFileSizeAfterWrite() throws Exception {
+    String testData = "abc19";
+
+    FileSystemUtils.writeContentAsLatin1(xFile, testData);
+    assertEquals(testData.length(), xFile.getFileSize());
+  }
+
+  // Testing the input/output routines
+  @Test
+  public void testFileWriteAndReadAsLatin1() throws Exception {
+    String testData = "abc19";
+
+    FileSystemUtils.writeContentAsLatin1(xFile, testData);
+    String resultData = new String(FileSystemUtils.readContentAsLatin1(xFile));
+
+    assertEquals(testData,resultData);
+  }
+
+  @Test
+  public void testInputAndOutputStreamEOF() throws Exception {
+    OutputStream outStream = xFile.getOutputStream();
+    outStream.write(1);
+    outStream.close();
+
+    InputStream inStream = xFile.getInputStream();
+    inStream.read();
+    assertEquals(-1, inStream.read());
+    inStream.close();
+  }
+
+  @Test
+  public void testInputAndOutputStream() throws Exception {
+    OutputStream outStream = xFile.getOutputStream();
+    for (int i = 33; i < 126; i++) {
+      outStream.write(i);
+    }
+    outStream.close();
+
+    InputStream inStream = xFile.getInputStream();
+    for (int i = 33; i < 126; i++) {
+      int readValue = inStream.read();
+      assertEquals(i,readValue);
+    }
+    inStream.close();
+  }
+
+  @Test
+  public void testInputAndOutputStreamAppend() throws Exception {
+    OutputStream outStream = xFile.getOutputStream();
+    for (int i = 33; i < 126; i++) {
+      outStream.write(i);
+    }
+    outStream.close();
+
+    OutputStream appendOut = xFile.getOutputStream(true);
+    for (int i = 126; i < 155; i++) {
+      appendOut.write(i);
+    }
+    appendOut.close();
+
+    InputStream inStream = xFile.getInputStream();
+    for (int i = 33; i < 155; i++) {
+      int readValue = inStream.read();
+      assertEquals(i,readValue);
+    }
+    inStream.close();
+  }
+
+  @Test
+  public void testInputAndOutputStreamNoAppend() throws Exception {
+    OutputStream outStream = xFile.getOutputStream();
+    outStream.write(1);
+    outStream.close();
+
+    OutputStream noAppendOut = xFile.getOutputStream(false);
+    noAppendOut.close();
+
+    InputStream inStream = xFile.getInputStream();
+    assertEquals(-1, inStream.read());
+    inStream.close();
+  }
+
+  @Test
+  public void testGetOutputStreamCreatesFile() throws Exception {
+    Path newFile = absolutize("does_not_exist_yet.txt");
+
+    OutputStream out = newFile.getOutputStream();
+    out.write(42);
+    out.close();
+
+    assertTrue(newFile.isFile());
+  }
+
+  @Test
+  public void testInpuStreamThrowExceptionOnDirectory() throws Exception {
+    try {
+      xEmptyDirectory.getOutputStream();
+      fail("The Exception was not thrown!");
+    } catch (IOException ex) {
+      assertEquals(xEmptyDirectory + " (Is a directory)", ex.getMessage());
+    }
+  }
+
+  @Test
+  public void testOutputStreamThrowExceptionOnDirectory() throws Exception {
+    try {
+      xEmptyDirectory.getInputStream();
+      fail("The Exception was not thrown!");
+    } catch (IOException ex) {
+      assertEquals(xEmptyDirectory + " (Is a directory)", ex.getMessage());
+    }
+  }
+
+  // Test renaming
+  @Test
+  public void testCanRenameToUnusedName() throws Exception {
+    xFile.renameTo(xNothing);
+    assertFalse(xFile.exists());
+    assertTrue(xNothing.isFile());
+  }
+
+  @Test
+  public void testCanRenameFileToExistingFile() throws Exception {
+    Path otherFile = absolutize("otherFile");
+    FileSystemUtils.createEmptyFile(otherFile);
+    xFile.renameTo(otherFile); // succeeds
+    assertFalse(xFile.exists());
+    assertTrue(otherFile.isFile());
+  }
+
+  @Test
+  public void testCanRenameDirToExistingEmptyDir() throws Exception {
+    xNonEmptyDirectory.renameTo(xEmptyDirectory); // succeeds
+    assertFalse(xNonEmptyDirectory.exists());
+    assertTrue(xEmptyDirectory.isDirectory());
+    assertFalse(xEmptyDirectory.getDirectoryEntries().isEmpty());
+  }
+
+  @Test
+  public void testCantRenameDirToExistingNonEmptyDir() throws Exception {
+    try {
+      xEmptyDirectory.renameTo(xNonEmptyDirectory);
+      fail();
+    } catch (IOException e) {
+      MoreAsserts.assertEndsWith(" (Directory not empty)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testCantRenameDirToExistingNonEmptyDirNothingChanged() throws Exception {
+    try {
+      xEmptyDirectory.renameTo(xNonEmptyDirectory);
+      fail();
+    } catch (IOException e) {
+      // Expected
+    }
+
+    assertTrue(xNonEmptyDirectory.isDirectory());
+    assertTrue(xEmptyDirectory.isDirectory());
+    assertTrue(xEmptyDirectory.getDirectoryEntries().isEmpty());
+    assertFalse(xNonEmptyDirectory.getDirectoryEntries().isEmpty());
+  }
+
+  @Test
+  public void testCantRenameDirToExistingFile() {
+    try {
+      xEmptyDirectory.renameTo(xFile);
+      fail();
+    } catch (IOException e) {
+      assertEquals(xEmptyDirectory + " -> " + xFile + " (Not a directory)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testCantRenameDirToExistingFileNothingChanged() {
+    try {
+      xEmptyDirectory.renameTo(xFile);
+      fail();
+    } catch (IOException e) {
+      // Expected
+    }
+
+    assertTrue(xEmptyDirectory.isDirectory());
+    assertTrue(xFile.isFile());
+  }
+
+  @Test
+  public void testCantRenameFileToExistingDir() {
+    try {
+      xFile.renameTo(xEmptyDirectory);
+      fail();
+    } catch (IOException e) {
+      assertEquals(xFile + " -> " + xEmptyDirectory + " (Is a directory)",
+                   e.getMessage());
+    }
+  }
+
+  @Test
+  public void testCantRenameFileToExistingDirNothingChanged() {
+    try {
+      xFile.renameTo(xEmptyDirectory);
+      fail();
+    } catch (IOException e) {
+      // Expected
+    }
+
+    assertTrue(xEmptyDirectory.isDirectory());
+    assertTrue(xFile.isFile());
+  }
+
+  @Test
+  public void testMoveOnNonExistingFileThrowsException() throws Exception {
+    Path nonExistingPath = absolutize("non-existing");
+    Path targetPath = absolutize("does-not-matter");
+    try {
+      nonExistingPath.renameTo(targetPath);
+      fail();
+    } catch (FileNotFoundException e) {
+      MoreAsserts.assertEndsWith(" (No such file or directory)", e.getMessage());
+    }
+  }
+
+  // Test the Paths
+  @Test
+  public void testGetPathOnlyAcceptsAbsolutePath() {
+    try {
+      testFS.getPath("not-absolute");
+      fail("The expected Exception was not thrown.");
+    } catch (IllegalArgumentException ex) {
+      assertEquals("not-absolute (not an absolute path)", ex.getMessage());
+    }
+  }
+
+  @Test
+  public void testGetPathOnlyAcceptsAbsolutePathFragment() {
+    try {
+      testFS.getPath(new PathFragment("not-absolute"));
+      fail("The expected Exception was not thrown.");
+    } catch (IllegalArgumentException ex) {
+      assertEquals("not-absolute (not an absolute path)", ex.getMessage());
+    }
+  }
+
+  // Test the access permissions
+  @Test
+  public void testNewFilesAreWritable() throws Exception {
+    assertTrue(xFile.isWritable());
+  }
+
+  @Test
+  public void testNewFilesAreReadable() throws Exception {
+    assertTrue(xFile.isReadable());
+  }
+
+  @Test
+  public void testNewDirsAreWritable() throws Exception {
+    assertTrue(xEmptyDirectory.isWritable());
+  }
+
+  @Test
+  public void testNewDirsAreReadable() throws Exception {
+    assertTrue(xEmptyDirectory.isReadable());
+  }
+
+  @Test
+  public void testNewDirsAreExecutable() throws Exception {
+    assertTrue(xEmptyDirectory.isExecutable());
+  }
+
+  @Test
+  public void testCannotGetExecutableOnNonexistingFile() throws Exception {
+    try {
+      xNothing.isExecutable();
+      fail("No exception thrown.");
+    } catch (FileNotFoundException ex) {
+      assertEquals(xNothing + " (No such file or directory)", ex.getMessage());
+    }
+  }
+
+  @Test
+  public void testCannotSetExecutableOnNonexistingFile() throws Exception {
+    try {
+      xNothing.setExecutable(true);
+      fail("No exception thrown.");
+    } catch (FileNotFoundException ex) {
+      assertEquals(xNothing + " (No such file or directory)", ex.getMessage());
+    }
+  }
+
+  @Test
+  public void testCannotGetWritableOnNonexistingFile() throws Exception {
+    try {
+      xNothing.isWritable();
+      fail("No exception thrown.");
+    } catch (FileNotFoundException ex) {
+      assertEquals(xNothing + " (No such file or directory)", ex.getMessage());
+    }
+  }
+
+  @Test
+  public void testCannotSetWritableOnNonexistingFile() throws Exception {
+    try {
+      xNothing.setWritable(false);
+      fail("No exception thrown.");
+    } catch (FileNotFoundException ex) {
+      assertEquals(xNothing + " (No such file or directory)", ex.getMessage());
+    }
+  }
+
+  @Test
+  public void testSetReadableOnFile() throws Exception {
+    xFile.setReadable(false);
+    assertFalse(xFile.isReadable());
+    xFile.setReadable(true);
+    assertTrue(xFile.isReadable());
+  }
+
+  @Test
+  public void testSetWritableOnFile() throws Exception {
+    xFile.setWritable(false);
+    assertFalse(xFile.isWritable());
+    xFile.setWritable(true);
+    assertTrue(xFile.isWritable());
+  }
+
+  @Test
+  public void testSetExecutableOnFile() throws Exception {
+    xFile.setExecutable(true);
+    assertTrue(xFile.isExecutable());
+    xFile.setExecutable(false);
+    assertFalse(xFile.isExecutable());
+  }
+
+  @Test
+  public void testSetExecutableOnDirectory() throws Exception {
+    setExecutable(xNonEmptyDirectory, false);
+
+    try {
+      // We can't map names->inodes in a non-executable directory:
+      xNonEmptyDirectoryFoo.isWritable(); // i.e. stat
+      fail();
+    } catch (IOException e) {
+      MoreAsserts.assertEndsWith(" (Permission denied)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testWritingToReadOnlyFileThrowsException() throws Exception {
+    xFile.setWritable(false);
+    try {
+      FileSystemUtils.writeContent(xFile, "hello, world!".getBytes());
+      fail("No exception thrown.");
+    } catch (IOException e) {
+      assertEquals(xFile + " (Permission denied)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testReadingFromUnreadableFileThrowsException() throws Exception {
+    FileSystemUtils.writeContent(xFile, "hello, world!".getBytes());
+    xFile.setReadable(false);
+    try {
+      FileSystemUtils.readContent(xFile);
+      fail("No exception thrown.");
+    } catch (IOException e) {
+      assertEquals(xFile + " (Permission denied)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testCannotCreateFileInReadOnlyDirectory() throws Exception {
+    Path xNonEmptyDirectoryBar = xNonEmptyDirectory.getChild("bar");
+    xNonEmptyDirectory.setWritable(false);
+
+    try {
+      FileSystemUtils.createEmptyFile(xNonEmptyDirectoryBar);
+      fail("No exception thrown.");
+    } catch (IOException e) {
+      assertEquals(xNonEmptyDirectoryBar + " (Permission denied)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testCannotCreateDirectoryInReadOnlyDirectory() throws Exception {
+    Path xNonEmptyDirectoryBar = xNonEmptyDirectory.getChild("bar");
+    xNonEmptyDirectory.setWritable(false);
+
+    try {
+      xNonEmptyDirectoryBar.createDirectory();
+      fail("No exception thrown.");
+    } catch (IOException e) {
+      assertEquals(xNonEmptyDirectoryBar + " (Permission denied)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testCannotMoveIntoReadOnlyDirectory() throws Exception {
+    Path xNonEmptyDirectoryBar = xNonEmptyDirectory.getChild("bar");
+    xNonEmptyDirectory.setWritable(false);
+
+    try {
+      xFile.renameTo(xNonEmptyDirectoryBar);
+      fail("No exception thrown.");
+    } catch (IOException e) {
+      MoreAsserts.assertEndsWith(" (Permission denied)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testCannotMoveFromReadOnlyDirectory() throws Exception {
+    xNonEmptyDirectory.setWritable(false);
+
+    try {
+      xNonEmptyDirectoryFoo.renameTo(xNothing);
+      fail("No exception thrown.");
+    } catch (IOException e) {
+      MoreAsserts.assertEndsWith(" (Permission denied)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testCannotDeleteInReadOnlyDirectory() throws Exception {
+    xNonEmptyDirectory.setWritable(false);
+
+    try {
+      xNonEmptyDirectoryFoo.delete();
+      fail("No exception thrown.");
+    } catch (IOException e) {
+      assertEquals(xNonEmptyDirectoryFoo + " (Permission denied)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testCannotCreatSymbolicLinkInReadOnlyDirectory() throws Exception {
+    Path xNonEmptyDirectoryBar = xNonEmptyDirectory.getChild("bar");
+    xNonEmptyDirectory.setWritable(false);
+
+    if (supportsSymlinks) {
+      try {
+        createSymbolicLink(xNonEmptyDirectoryBar, xNonEmptyDirectoryFoo);
+        fail("No exception thrown.");
+      } catch (IOException e) {
+        assertEquals(xNonEmptyDirectoryBar + " (Permission denied)", e.getMessage());
+      }
+    }
+  }
+
+  @Test
+  public void testGetMD5DigestForEmptyFile() throws Exception {
+    Fingerprint fp = new Fingerprint();
+    fp.addBytes(new byte[0]);
+    assertEquals(BaseEncoding.base16().lowerCase().encode(xFile.getMD5Digest()),
+        fp.hexDigestAndReset());
+  }
+
+  @Test
+  public void testGetMD5Digest() throws Exception {
+    byte[] buffer = new byte[500000];
+    for (int i = 0; i < buffer.length; ++i) {
+      buffer[i] = 1;
+    }
+    FileSystemUtils.writeContent(xFile, buffer);
+    Fingerprint fp = new Fingerprint();
+    fp.addBytes(buffer);
+    assertEquals(BaseEncoding.base16().lowerCase().encode(xFile.getMD5Digest()),
+        fp.hexDigestAndReset());
+  }
+
+  @Test
+  public void testStatFailsFastOnNonExistingFiles() throws Exception {
+    try {
+      xNothing.stat();
+      fail("Expected IOException");
+    } catch(IOException e) {
+      // Do nothing.
+    }
+  }
+
+  @Test
+  public void testStatNullableFailsFastOnNonExistingFiles() throws Exception {
+    assertNull(xNothing.statNullable());
+  }
+
+  @Test
+  public void testResolveSymlinks() throws Exception {
+    if (supportsSymlinks) {
+      createSymbolicLink(xNothing, xFile);
+      FileSystemUtils.createEmptyFile(xFile);
+      assertEquals(xFile.asFragment(), testFS.resolveOneLink(xNothing));
+      assertEquals(xFile, xNothing.resolveSymbolicLinks());
+    }
+  }
+
+  @Test
+  public void testResolveNonSymlinks() throws Exception {
+    if (supportsSymlinks) {
+      assertEquals(null, testFS.resolveOneLink(xFile));
+      assertEquals(xFile, xFile.resolveSymbolicLinks());
+    }
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/FileSystemUtilsTest.java b/src/test/java/com/google/devtools/build/lib/vfs/FileSystemUtilsTest.java
new file mode 100644
index 0000000..21ca39b
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/FileSystemUtilsTest.java
@@ -0,0 +1,878 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.build.lib.vfs.FileSystemUtils.appendWithoutExtension;
+import static com.google.devtools.build.lib.vfs.FileSystemUtils.commonAncestor;
+import static com.google.devtools.build.lib.vfs.FileSystemUtils.copyFile;
+import static com.google.devtools.build.lib.vfs.FileSystemUtils.copyTool;
+import static com.google.devtools.build.lib.vfs.FileSystemUtils.createDirectoryAndParents;
+import static com.google.devtools.build.lib.vfs.FileSystemUtils.deleteTree;
+import static com.google.devtools.build.lib.vfs.FileSystemUtils.deleteTreesBelowNotPrefixed;
+import static com.google.devtools.build.lib.vfs.FileSystemUtils.longestPathPrefix;
+import static com.google.devtools.build.lib.vfs.FileSystemUtils.plantLinkForest;
+import static com.google.devtools.build.lib.vfs.FileSystemUtils.relativePath;
+import static com.google.devtools.build.lib.vfs.FileSystemUtils.removeExtension;
+import static com.google.devtools.build.lib.vfs.FileSystemUtils.touchFile;
+import static com.google.devtools.build.lib.vfs.FileSystemUtils.traverseTree;
+import static java.nio.charset.StandardCharsets.ISO_8859_1;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.base.Predicate;
+import com.google.common.base.Predicates;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.devtools.build.lib.testutil.BlazeTestUtils;
+import com.google.devtools.build.lib.testutil.ManualClock;
+import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * This class tests the file system utilities.
+ */
+@RunWith(JUnit4.class)
+public class FileSystemUtilsTest {
+  private ManualClock clock;
+  private FileSystem fileSystem;
+  private Path workingDir;
+
+  @Before
+  public void setUp() throws Exception {
+    clock = new ManualClock();
+    fileSystem = new InMemoryFileSystem(clock);
+    workingDir = fileSystem.getPath("/workingDir");
+  }
+
+  Path topDir;
+  Path file1;
+  Path file2;
+  Path aDir;
+  Path file3;
+  Path innerDir;
+  Path link1;
+  Path dirLink;
+  Path file4;
+
+  /*
+   * Build a directory tree that looks like:
+   *   top-dir/
+   *     file-1
+   *     file-2
+   *     a-dir/
+   *       file-3
+   *       inner-dir/
+   *         link-1 => file-4
+   *         dir-link => a-dir
+   *   file-4
+   */
+  private void createTestDirectoryTree() throws IOException {
+    topDir = fileSystem.getPath("/top-dir");
+    file1 = fileSystem.getPath("/top-dir/file-1");
+    file2 = fileSystem.getPath("/top-dir/file-2");
+    aDir = fileSystem.getPath("/top-dir/a-dir");
+    file3 = fileSystem.getPath("/top-dir/a-dir/file-3");
+    innerDir = fileSystem.getPath("/top-dir/a-dir/inner-dir");
+    link1 = fileSystem.getPath("/top-dir/a-dir/inner-dir/link-1");
+    dirLink = fileSystem.getPath("/top-dir/a-dir/inner-dir/dir-link");
+    file4 = fileSystem.getPath("/file-4");
+
+    topDir.createDirectory();
+    FileSystemUtils.createEmptyFile(file1);
+    FileSystemUtils.createEmptyFile(file2);
+    aDir.createDirectory();
+    FileSystemUtils.createEmptyFile(file3);
+    innerDir.createDirectory();
+    link1.createSymbolicLink(file4);  // simple symlink
+    dirLink.createSymbolicLink(aDir); // creates link loop
+    FileSystemUtils.createEmptyFile(file4);
+  }
+
+  private void checkTestDirectoryTreesBelow(Path toPath) throws IOException {
+    Path copiedFile1 = toPath.getChild("file-1");
+    assertTrue(copiedFile1.exists());
+    assertTrue(copiedFile1.isFile());
+
+    Path copiedFile2 = toPath.getChild("file-2");
+    assertTrue(copiedFile2.exists());
+    assertTrue(copiedFile2.isFile());
+
+    Path copiedADir = toPath.getChild("a-dir");
+    assertTrue(copiedADir.exists());
+    assertTrue(copiedADir.isDirectory());
+    Collection<Path> aDirEntries = copiedADir.getDirectoryEntries();
+    assertEquals(2, aDirEntries.size());
+
+    Path copiedFile3 = copiedADir.getChild("file-3");
+    assertTrue(copiedFile3.exists());
+    assertTrue(copiedFile3.isFile());
+
+    Path copiedInnerDir = copiedADir.getChild("inner-dir");
+    assertTrue(copiedInnerDir.exists());
+    assertTrue(copiedInnerDir.isDirectory());
+
+    Path copiedLink1 = copiedInnerDir.getChild("link-1");
+    assertTrue(copiedLink1.exists());
+    assertTrue(copiedLink1.isSymbolicLink());
+    assertEquals(copiedLink1.resolveSymbolicLinks(), file4);
+
+    Path copiedDirLink = copiedInnerDir.getChild("dir-link");
+    assertTrue(copiedDirLink.exists());
+    assertTrue(copiedDirLink.isSymbolicLink());
+    assertEquals(copiedDirLink.resolveSymbolicLinks(), aDir);
+  }
+
+  // tests
+
+  @Test
+  public void testChangeModtime() throws IOException {
+    Path file = fileSystem.getPath("/my-file");
+    try {
+      BlazeTestUtils.changeModtime(file);
+      fail();
+    } catch (FileNotFoundException e) {
+      /* ok */
+    }
+    FileSystemUtils.createEmptyFile(file);
+    long prevMtime = file.getLastModifiedTime();
+    BlazeTestUtils.changeModtime(file);
+    assertFalse(prevMtime == file.getLastModifiedTime());
+  }
+
+  @Test
+  public void testCommonAncestor() {
+    assertEquals(topDir, commonAncestor(topDir, topDir));
+    assertEquals(topDir, commonAncestor(file1, file3));
+    assertEquals(topDir, commonAncestor(file1, dirLink));
+  }
+
+  @Test
+  public void testRelativePath() throws IOException {
+    createTestDirectoryTree();
+    assertEquals("file-1", relativePath(topDir, file1).getPathString());
+    assertEquals(".", relativePath(topDir, topDir).getPathString());
+    assertEquals("a-dir/inner-dir/dir-link", relativePath(topDir, dirLink).getPathString());
+    assertEquals("../file-4", relativePath(topDir, file4).getPathString());
+    assertEquals("../../../file-4", relativePath(innerDir, file4).getPathString());
+  }
+
+  private String longestPathPrefixStr(String path, String... prefixStrs) {
+    Set<PathFragment> prefixes = new HashSet<>();
+    for (String prefix : prefixStrs) {
+      prefixes.add(new PathFragment(prefix));
+    }
+    PathFragment longest = longestPathPrefix(new PathFragment(path), prefixes);
+    return longest != null ? longest.getPathString() : null;
+  }
+
+  @Test
+  public void testLongestPathPrefix() {
+    assertEquals("A", longestPathPrefixStr("A/b", "A", "B")); // simple parent
+    assertEquals("A", longestPathPrefixStr("A", "A", "B")); // self
+    assertEquals("A/B", longestPathPrefixStr("A/B/c", "A", "A/B"));  // want longest
+    assertNull(longestPathPrefixStr("C/b", "A", "B"));  // not found in other parents
+    assertNull(longestPathPrefixStr("A", "A/B", "B"));  // not found in child
+    assertEquals("A/B/C", longestPathPrefixStr("A/B/C/d/e/f.h", "A/B/C", "B/C/d"));
+  }
+
+  @Test
+  public void testRemoveExtension_Strings() throws Exception {
+    assertEquals("foo", removeExtension("foo.c"));
+    assertEquals("a/foo", removeExtension("a/foo.c"));
+    assertEquals("a.b/foo", removeExtension("a.b/foo"));
+    assertEquals("foo", removeExtension("foo"));
+    assertEquals("foo", removeExtension("foo."));
+  }
+
+  @Test
+  public void testRemoveExtension_Paths() throws Exception {
+    assertPath("/foo", removeExtension(fileSystem.getPath("/foo.c")));
+    assertPath("/a/foo", removeExtension(fileSystem.getPath("/a/foo.c")));
+    assertPath("/a.b/foo", removeExtension(fileSystem.getPath("/a.b/foo")));
+    assertPath("/foo", removeExtension(fileSystem.getPath("/foo")));
+    assertPath("/foo", removeExtension(fileSystem.getPath("/foo.")));
+  }
+
+  private static void assertPath(String expected, PathFragment actual) {
+    assertEquals(expected, actual.getPathString());
+  }
+
+  private static void assertPath(String expected, Path actual) {
+    assertEquals(expected, actual.getPathString());
+  }
+
+  @Test
+  public void testReplaceExtension_Path() throws Exception {
+    assertPath("/foo/bar.baz",
+               FileSystemUtils.replaceExtension(fileSystem.getPath("/foo/bar"), ".baz"));
+    assertPath("/foo/bar.baz",
+               FileSystemUtils.replaceExtension(fileSystem.getPath("/foo/bar.cc"), ".baz"));
+    assertPath("/foo.baz", FileSystemUtils.replaceExtension(fileSystem.getPath("/foo/"), ".baz"));
+    assertPath("/foo.baz",
+               FileSystemUtils.replaceExtension(fileSystem.getPath("/foo.cc/"), ".baz"));
+    assertPath("/foo.baz", FileSystemUtils.replaceExtension(fileSystem.getPath("/foo"), ".baz"));
+    assertPath("/foo.baz", FileSystemUtils.replaceExtension(fileSystem.getPath("/foo.cc"), ".baz"));
+    assertPath("/.baz", FileSystemUtils.replaceExtension(fileSystem.getPath("/.cc"), ".baz"));
+    assertEquals(null, FileSystemUtils.replaceExtension(fileSystem.getPath("/"), ".baz"));
+  }
+
+  @Test
+  public void testReplaceExtension_PathFragment() throws Exception {
+    assertPath("foo/bar.baz",
+               FileSystemUtils.replaceExtension(new PathFragment("foo/bar"), ".baz"));
+    assertPath("foo/bar.baz",
+               FileSystemUtils.replaceExtension(new PathFragment("foo/bar.cc"), ".baz"));
+    assertPath("/foo/bar.baz",
+               FileSystemUtils.replaceExtension(new PathFragment("/foo/bar"), ".baz"));
+    assertPath("/foo/bar.baz",
+               FileSystemUtils.replaceExtension(new PathFragment("/foo/bar.cc"), ".baz"));
+    assertPath("foo.baz", FileSystemUtils.replaceExtension(new PathFragment("foo/"), ".baz"));
+    assertPath("foo.baz", FileSystemUtils.replaceExtension(new PathFragment("foo.cc/"), ".baz"));
+    assertPath("/foo.baz", FileSystemUtils.replaceExtension(new PathFragment("/foo/"), ".baz"));
+    assertPath("/foo.baz",
+               FileSystemUtils.replaceExtension(new PathFragment("/foo.cc/"), ".baz"));
+    assertPath("foo.baz", FileSystemUtils.replaceExtension(new PathFragment("foo"), ".baz"));
+    assertPath("foo.baz", FileSystemUtils.replaceExtension(new PathFragment("foo.cc"), ".baz"));
+    assertPath("/foo.baz", FileSystemUtils.replaceExtension(new PathFragment("/foo"), ".baz"));
+    assertPath("/foo.baz",
+               FileSystemUtils.replaceExtension(new PathFragment("/foo.cc"), ".baz"));
+    assertPath(".baz", FileSystemUtils.replaceExtension(new PathFragment(".cc"), ".baz"));
+    assertEquals(null, FileSystemUtils.replaceExtension(new PathFragment("/"), ".baz"));
+    assertEquals(null, FileSystemUtils.replaceExtension(new PathFragment(""), ".baz"));
+    assertPath("foo/bar.baz",
+        FileSystemUtils.replaceExtension(new PathFragment("foo/bar.pony"), ".baz", ".pony"));
+    assertPath("foo/bar.baz",
+        FileSystemUtils.replaceExtension(new PathFragment("foo/bar"), ".baz", ""));
+    assertEquals(null, FileSystemUtils.replaceExtension(new PathFragment(""), ".baz", ".pony"));
+    assertEquals(null,
+        FileSystemUtils.replaceExtension(new PathFragment("foo/bar.pony"), ".baz", ".unicorn"));
+  }
+
+  @Test
+  public void testAppendWithoutExtension() throws Exception {
+    assertPath("libfoo-src.jar",
+        appendWithoutExtension(new PathFragment("libfoo.jar"), "-src"));
+    assertPath("foo/libfoo-src.jar",
+        appendWithoutExtension(new PathFragment("foo/libfoo.jar"), "-src"));
+    assertPath("java/com/google/foo/libfoo-src.jar",
+        appendWithoutExtension(new PathFragment("java/com/google/foo/libfoo.jar"), "-src"));
+    assertPath("libfoo.bar-src.jar",
+        appendWithoutExtension(new PathFragment("libfoo.bar.jar"), "-src"));
+    assertPath("libfoo-src",
+        appendWithoutExtension(new PathFragment("libfoo"), "-src"));
+    assertPath("libfoo-src.jar",
+        appendWithoutExtension(new PathFragment("libfoo.jar/"), "-src"));
+    assertPath("libfoo.src.jar",
+        appendWithoutExtension(new PathFragment("libfoo.jar"), ".src"));
+    assertEquals(null, appendWithoutExtension(new PathFragment("/"), "-src"));
+    assertEquals(null, appendWithoutExtension(new PathFragment(""), "-src"));
+  }
+
+  @Test
+  public void testReplaceSegments() {
+    assertPath(
+        "poo/bar/baz.cc",
+        FileSystemUtils.replaceSegments(new PathFragment("foo/bar/baz.cc"), "foo", "poo", true));
+    assertPath(
+        "poo/poo/baz.cc",
+        FileSystemUtils.replaceSegments(new PathFragment("foo/foo/baz.cc"), "foo", "poo", true));
+    assertPath(
+        "poo/foo/baz.cc",
+        FileSystemUtils.replaceSegments(new PathFragment("foo/foo/baz.cc"), "foo", "poo", false));
+    assertPath(
+        "foo/bar/baz.cc",
+        FileSystemUtils.replaceSegments(new PathFragment("foo/bar/baz.cc"), "boo", "poo", true));
+  }
+
+  @Test
+  public void testGetWorkingDirectory() {
+    String userDir = System.getProperty("user.dir");
+
+    assertEquals(FileSystemUtils.getWorkingDirectory(fileSystem),
+        fileSystem.getPath(System.getProperty("user.dir", "/")));
+
+    System.setProperty("user.dir", "/blah/blah/blah");
+    assertEquals(FileSystemUtils.getWorkingDirectory(fileSystem),
+        fileSystem.getPath("/blah/blah/blah"));
+
+    System.setProperty("user.dir", userDir);
+  }
+
+  @Test
+  public void testResolveRelativeToFilesystemWorkingDir() {
+    PathFragment relativePath = new PathFragment("relative/path");
+    assertEquals(workingDir.getRelative(relativePath),
+                 workingDir.getRelative(relativePath));
+
+    PathFragment absolutePath = new PathFragment("/absolute/path");
+    assertEquals(fileSystem.getPath(absolutePath),
+                 workingDir.getRelative(absolutePath));
+  }
+
+  @Test
+  public void testTouchFileCreatesFile() throws IOException {
+    createTestDirectoryTree();
+    Path nonExistingFile = fileSystem.getPath("/previously-non-existing");
+    assertFalse(nonExistingFile.exists());
+    touchFile(nonExistingFile);
+
+    assertTrue(nonExistingFile.exists());
+  }
+
+  @Test
+  public void testTouchFileAdjustsFileTime() throws IOException {
+    createTestDirectoryTree();
+    Path testFile = file4;
+    long oldTime = testFile.getLastModifiedTime();
+    testFile.setLastModifiedTime(42);
+    touchFile(testFile);
+
+    assertTrue(testFile.getLastModifiedTime() >= oldTime);
+  }
+
+  @Test
+  public void testCopyFile() throws IOException {
+    createTestDirectoryTree();
+    Path originalFile = file1;
+    byte[] content = new byte[] { 'a', 'b', 'c', 23, 42 };
+    FileSystemUtils.writeContent(originalFile, content);
+
+    Path copyTarget = file2;
+
+    copyFile(originalFile, copyTarget);
+
+    assertTrue(Arrays.equals(content, FileSystemUtils.readContent(copyTarget)));
+  }
+
+  @Test
+  public void testReadContentWithLimit() throws IOException {
+    createTestDirectoryTree();
+    String str = "this is a test of readContentWithLimit method";
+    FileSystemUtils.writeContent(file1, StandardCharsets.ISO_8859_1, str);
+    assertEquals(readStringFromFile(file1, 0), "");
+    assertEquals(readStringFromFile(file1, 10), str.substring(0, 10));
+    assertEquals(readStringFromFile(file1, 1000000), str);
+  }
+
+  private String readStringFromFile(Path file, int limit) throws IOException {
+    byte[] bytes = FileSystemUtils.readContentWithLimit(file, limit);
+    return new String(bytes, StandardCharsets.ISO_8859_1);
+  }
+
+  @Test
+  public void testAppend() throws IOException {
+    createTestDirectoryTree();
+    FileSystemUtils.writeIsoLatin1(file1, "nobody says ");
+    FileSystemUtils.writeIsoLatin1(file1, "mary had");
+    FileSystemUtils.appendIsoLatin1(file1, "a little lamb");
+    assertEquals(
+        "mary had\na little lamb\n",
+        new String(FileSystemUtils.readContentAsLatin1(file1)));
+  }
+
+  @Test
+  public void testCopyFileAttributes() throws IOException {
+    createTestDirectoryTree();
+    Path originalFile = file1;
+    byte[] content = new byte[] { 'a', 'b', 'c', 23, 42 };
+    FileSystemUtils.writeContent(originalFile, content);
+    file1.setLastModifiedTime(12345L);
+    file1.setWritable(false);
+    file1.setExecutable(false);
+
+    Path copyTarget = file2;
+    copyFile(originalFile, copyTarget);
+
+    assertEquals(12345L, file2.getLastModifiedTime());
+    assertFalse(file2.isExecutable());
+    assertFalse(file2.isWritable());
+
+    file1.setWritable(true);
+    file1.setExecutable(true);
+
+    copyFile(originalFile, copyTarget);
+
+    assertEquals(12345L, file2.getLastModifiedTime());
+    assertTrue(file2.isExecutable());
+    assertTrue(file2.isWritable());
+
+  }
+
+  @Test
+  public void testCopyFileThrowsExceptionIfTargetCantBeDeleted() throws IOException {
+    createTestDirectoryTree();
+    Path originalFile = file1;
+    byte[] content = new byte[] { 'a', 'b', 'c', 23, 42 };
+    FileSystemUtils.writeContent(originalFile, content);
+
+    try {
+      copyFile(originalFile, aDir);
+      fail();
+    } catch (IOException ex) {
+      assertEquals("error copying file: couldn't delete destination: "
+                   + aDir + " (Directory not empty)",
+                   ex.getMessage());
+    }
+  }
+
+  @Test
+  public void testCopyTool() throws IOException {
+    createTestDirectoryTree();
+    Path originalFile = file1;
+    byte[] content = new byte[] { 'a', 'b', 'c', 23, 42 };
+    FileSystemUtils.writeContent(originalFile, content);
+
+    Path copyTarget = copyTool(topDir.getRelative("file-1"), aDir.getRelative("file-1"));
+
+    assertTrue(Arrays.equals(content, FileSystemUtils.readContent(copyTarget)));
+    assertEquals(file1.isWritable(), copyTarget.isWritable());
+    assertEquals(file1.isExecutable(), copyTarget.isExecutable());
+    assertEquals(file1.getLastModifiedTime(), copyTarget.getLastModifiedTime());
+  }
+
+  @Test
+  public void testCopyTreesBelow() throws IOException {
+    createTestDirectoryTree();
+    Path toPath = fileSystem.getPath("/copy-here");
+    toPath.createDirectory();
+
+    FileSystemUtils.copyTreesBelow(topDir, toPath);
+    checkTestDirectoryTreesBelow(toPath);
+  }
+
+  @Test
+  public void testCopyTreesBelowWithOverriding() throws IOException {
+    createTestDirectoryTree();
+    Path toPath = fileSystem.getPath("/copy-here");
+    toPath.createDirectory();
+    toPath.getChild("file-2");
+
+    FileSystemUtils.copyTreesBelow(topDir, toPath);
+    checkTestDirectoryTreesBelow(toPath);
+  }
+
+  @Test
+  public void testCopyTreesBelowToSubtree() throws IOException {
+    createTestDirectoryTree();
+    try {
+      FileSystemUtils.copyTreesBelow(topDir, aDir);
+      fail("Should not be able to copy a directory to a subdir");
+    } catch (IllegalArgumentException expected) {
+      assertEquals("/top-dir/a-dir is a subdirectory of /top-dir", expected.getMessage());
+    }
+  }
+
+  @Test
+  public void testCopyFileAsDirectoryTree() throws IOException {
+    createTestDirectoryTree();
+    try {
+      FileSystemUtils.copyTreesBelow(file1, aDir);
+      fail("Should not be able to copy a file with copyDirectory method");
+    } catch (IOException expected) {
+      assertEquals("/top-dir/file-1 (Not a directory)", expected.getMessage());
+    }
+  }
+
+  @Test
+  public void testCopyTreesBelowToFile() throws IOException {
+    createTestDirectoryTree();
+    Path copyDir = fileSystem.getPath("/my-dir");
+    Path copySubDir = fileSystem.getPath("/my-dir/subdir");
+    FileSystemUtils.createDirectoryAndParents(copySubDir);
+    try {
+      FileSystemUtils.copyTreesBelow(copyDir, file4);
+      fail("Should not be able to copy a directory to a file");
+    } catch (IOException expected) {
+      assertEquals("/file-4 (Not a directory)", expected.getMessage());
+    }
+  }
+
+  @Test
+  public void testCopyTreesBelowFromUnexistingDir() throws IOException {
+    createTestDirectoryTree();
+
+    try {
+      Path unexistingDir = fileSystem.getPath("/unexisting-dir");
+      FileSystemUtils.copyTreesBelow(unexistingDir, aDir);
+      fail("Should not be able to copy from an unexisting path");
+    } catch (FileNotFoundException expected) {
+      assertEquals("/unexisting-dir (No such file or directory)", expected.getMessage());
+    }
+  }
+
+  @Test
+  public void testTraverseTree() throws IOException {
+    createTestDirectoryTree();
+
+    Collection<Path> paths = traverseTree(topDir, new Predicate<Path>() {
+      @Override
+      public boolean apply(Path p) {
+        return !p.getPathString().contains("a-dir");
+      }
+    });
+    assertThat(paths).containsExactly(file1, file2);
+  }
+
+  @Test
+  public void testTraverseTreeDeep() throws IOException {
+    createTestDirectoryTree();
+
+    Collection<Path> paths = traverseTree(topDir,
+        Predicates.alwaysTrue());
+    assertThat(paths).containsExactly(aDir,
+        file3,
+        innerDir,
+        link1,
+        file1,
+        file2,
+        dirLink);
+  }
+
+  @Test
+  public void testTraverseTreeLinkDir() throws IOException {
+    // Use a new little tree for this test:
+    //  top-dir/
+    //    dir-link2 => linked-dir
+    //  linked-dir/
+    //    file
+    topDir = fileSystem.getPath("/top-dir");
+    Path dirLink2 = fileSystem.getPath("/top-dir/dir-link2");
+    Path linkedDir = fileSystem.getPath("/linked-dir");
+    Path linkedDirFile = fileSystem.getPath("/top-dir/dir-link2/file");
+
+    topDir.createDirectory();
+    linkedDir.createDirectory();
+    dirLink2.createSymbolicLink(linkedDir);  // simple symlink
+    FileSystemUtils.createEmptyFile(linkedDirFile);  // created through the link
+
+    // traverseTree doesn't follow links:
+    Collection<Path> paths = traverseTree(topDir, Predicates.alwaysTrue());
+    assertThat(paths).containsExactly(dirLink2);
+
+    paths = traverseTree(linkedDir, Predicates.alwaysTrue());
+    assertThat(paths).containsExactly(fileSystem.getPath("/linked-dir/file"));
+  }
+
+  @Test
+  public void testDeleteTreeCommandDeletesTree() throws IOException {
+    createTestDirectoryTree();
+    Path toDelete = topDir;
+    deleteTree(toDelete);
+
+    assertTrue(file4.exists());
+    assertFalse(topDir.exists());
+    assertFalse(file1.exists());
+    assertFalse(file2.exists());
+    assertFalse(aDir.exists());
+    assertFalse(file3.exists());
+  }
+
+  @Test
+  public void testDeleteTreeCommandsDeletesUnreadableDirectories() throws IOException {
+    createTestDirectoryTree();
+    Path toDelete = topDir;
+
+    try {
+      aDir.setReadable(false);
+    } catch (UnsupportedOperationException e) {
+      // For file systems that do not support setting readable attribute to
+      // false, this test is simply skipped.
+
+      return;
+    }
+
+    deleteTree(toDelete);
+    assertFalse(topDir.exists());
+    assertFalse(aDir.exists());
+
+  }
+
+  @Test
+  public void testDeleteTreeCommandDoesNotFollowLinksOut() throws IOException {
+    createTestDirectoryTree();
+    Path toDelete = topDir;
+    Path outboundLink = fileSystem.getPath("/top-dir/outbound-link");
+    outboundLink.createSymbolicLink(file4);
+
+    deleteTree(toDelete);
+
+    assertTrue(file4.exists());
+    assertFalse(topDir.exists());
+    assertFalse(file1.exists());
+    assertFalse(file2.exists());
+    assertFalse(aDir.exists());
+    assertFalse(file3.exists());
+  }
+
+  @Test
+  public void testDeleteTreesBelowNotPrefixed() throws IOException {
+    createTestDirectoryTree();
+    deleteTreesBelowNotPrefixed(topDir, new String[] { "file-"});
+    assertTrue(file1.exists());
+    assertTrue(file2.exists());
+    assertFalse(aDir.exists());
+  }
+
+  @Test
+  public void testCreateDirectories() throws IOException {
+    Path mainPath = fileSystem.getPath("/some/where/deep/in/the/hierarchy");
+    assertTrue(createDirectoryAndParents(mainPath));
+    assertTrue(mainPath.exists());
+    assertFalse(createDirectoryAndParents(mainPath));
+  }
+
+  @Test
+  public void testCreateDirectoriesWhenAncestorIsFile() throws IOException {
+    Path somewhereDeepIn = fileSystem.getPath("/somewhere/deep/in");
+    assertTrue(createDirectoryAndParents(somewhereDeepIn.getParentDirectory()));
+    FileSystemUtils.createEmptyFile(somewhereDeepIn);
+    Path theHierarchy = somewhereDeepIn.getChild("the-hierarchy");
+    try {
+      createDirectoryAndParents(theHierarchy);
+      fail();
+    } catch (IOException e) {
+      assertEquals("/somewhere/deep/in (Not a directory)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testCreateDirectoriesWhenSymlinkToDir() throws IOException {
+    Path somewhereDeepIn = fileSystem.getPath("/somewhere/deep/in");
+    assertTrue(createDirectoryAndParents(somewhereDeepIn));
+    Path realDir = fileSystem.getPath("/real/dir");
+    assertTrue(createDirectoryAndParents(realDir));
+
+    Path theHierarchy = somewhereDeepIn.getChild("the-hierarchy");
+    theHierarchy.createSymbolicLink(realDir);
+
+    assertFalse(createDirectoryAndParents(theHierarchy));
+  }
+
+  @Test
+  public void testCreateDirectoriesWhenSymlinkEmbedded() throws IOException {
+    Path somewhereDeepIn = fileSystem.getPath("/somewhere/deep/in");
+    assertTrue(createDirectoryAndParents(somewhereDeepIn));
+    Path realDir = fileSystem.getPath("/real/dir");
+    assertTrue(createDirectoryAndParents(realDir));
+
+    Path the = somewhereDeepIn.getChild("the");
+    the.createSymbolicLink(realDir);
+
+    Path theHierarchy = somewhereDeepIn.getChild("hierarchy");
+    assertTrue(createDirectoryAndParents(theHierarchy));
+  }
+
+  PathFragment createPkg(Path rootA, Path rootB, String pkg) throws IOException {
+    if (rootA != null) {
+      createDirectoryAndParents(rootA.getRelative(pkg));
+      FileSystemUtils.createEmptyFile(rootA.getRelative(pkg).getChild("file"));
+    }
+    if (rootB != null) {
+      createDirectoryAndParents(rootB.getRelative(pkg));
+      FileSystemUtils.createEmptyFile(rootB.getRelative(pkg).getChild("file"));
+    }
+    return new PathFragment(pkg);
+  }
+
+  void assertLinksTo(Path fromRoot, Path toRoot, String relpart) throws IOException {
+    assertTrue(fromRoot.getRelative(relpart).isSymbolicLink());
+    assertEquals(toRoot.getRelative(relpart).asFragment(),
+                 fromRoot.getRelative(relpart).readSymbolicLink());
+  }
+
+  void assertIsDir(Path root, String relpart) {
+    assertTrue(root.getRelative(relpart).isDirectory(Symlinks.NOFOLLOW));
+  }
+
+  void dumpTree(Path root, PrintStream out) throws IOException {
+    out.println("\n" + root);
+    for (Path p : FileSystemUtils.traverseTree(root, Predicates.alwaysTrue())) {
+      if (p.isDirectory(Symlinks.NOFOLLOW)) {
+        out.println("  " + p + "/");
+      } else if (p.isSymbolicLink()) {
+        out.println("  " + p + " => " + p.readSymbolicLink());
+      } else {
+        out.println("  " + p + " [" + p.resolveSymbolicLinks() + "]");
+      }
+    }
+  }
+
+  @Test
+  public void testPlantLinkForest() throws IOException {
+    Path rootA = fileSystem.getPath("/A");
+    Path rootB = fileSystem.getPath("/B");
+
+    ImmutableMap<PathFragment, Path> packageRootMap = ImmutableMap.<PathFragment, Path>builder()
+        .put(createPkg(rootA, rootB, "pkgA"), rootA)
+        .put(createPkg(rootA, rootB, "dir1/pkgA"), rootA)
+        .put(createPkg(rootA, rootB, "dir1/pkgB"), rootB)
+        .put(createPkg(rootA, rootB, "dir2/pkg"), rootA)
+        .put(createPkg(rootA, rootB, "dir2/pkg/pkg"), rootB)
+        .put(createPkg(rootA, rootB, "pkgB"), rootB)
+        .put(createPkg(rootA, rootB, "pkgB/dir/pkg"), rootA)
+        .put(createPkg(rootA, rootB, "pkgB/pkg"), rootA)
+        .put(createPkg(rootA, rootB, "pkgB/pkg/pkg"), rootA)
+        .build();
+    createPkg(rootA, rootB, "pkgB/dir");  // create a file in there
+
+    //dumpTree(rootA, System.err);
+    //dumpTree(rootB, System.err);
+
+    Path linkRoot = fileSystem.getPath("/linkRoot");
+    createDirectoryAndParents(linkRoot);
+    plantLinkForest(packageRootMap, linkRoot);
+
+    //dumpTree(linkRoot, System.err);
+
+    assertLinksTo(linkRoot, rootA, "pkgA");
+    assertIsDir(linkRoot, "dir1");
+    assertLinksTo(linkRoot, rootA, "dir1/pkgA");
+    assertLinksTo(linkRoot, rootB, "dir1/pkgB");
+    assertIsDir(linkRoot, "dir2");
+    assertIsDir(linkRoot, "dir2/pkg");
+    assertLinksTo(linkRoot, rootA, "dir2/pkg/file");
+    assertLinksTo(linkRoot, rootB, "dir2/pkg/pkg");
+    assertIsDir(linkRoot, "pkgB");
+    assertIsDir(linkRoot, "pkgB/dir");
+    assertLinksTo(linkRoot, rootB, "pkgB/dir/file");
+    assertLinksTo(linkRoot, rootA, "pkgB/dir/pkg");
+    assertLinksTo(linkRoot, rootA, "pkgB/pkg");
+  }
+
+  @Test
+  public void testWriteIsoLatin1() throws Exception {
+    Path file = fileSystem.getPath("/does/not/exist/yet.txt");
+    FileSystemUtils.writeIsoLatin1(file, "Line 1", "Line 2", "Line 3");
+    String expected = "Line 1\nLine 2\nLine 3\n";
+    String actual = new String(FileSystemUtils.readContentAsLatin1(file));
+    assertEquals(expected, actual);
+  }
+
+  @Test
+  public void testWriteLinesAs() throws Exception {
+    Path file = fileSystem.getPath("/does/not/exist/yet.txt");
+    FileSystemUtils.writeLinesAs(file, UTF_8, "\u00F6"); // an oe umlaut
+    byte[] expected = new byte[] {(byte) 0xC3, (byte) 0xB6, 0x0A};//"\u00F6\n";
+    byte[] actual = FileSystemUtils.readContent(file);
+    assertArrayEquals(expected, actual);
+  }
+
+  @Test
+  public void testGetFileSystem() throws Exception {
+    Path mountTable = fileSystem.getPath("/proc/mounts");
+    FileSystemUtils.writeIsoLatin1(mountTable,
+        "/dev/sda1 / ext2 blah 0 0",
+        "/dev/mapper/_dev_sda6 /usr/local/google ext3 blah 0 0",
+        "devshm /dev/shm tmpfs blah 0 0",
+        "/dev/fuse /fuse/mnt fuse blah 0 0",
+        "mtvhome22.nfs:/vol/mtvhome22/johndoe /home/johndoe nfs blah 0 0",
+        "/dev/foo /foo dummy_foo blah 0 0",
+        "/dev/foobar /foobar dummy_foobar blah 0 0",
+        "proc proc proc rw,noexec,nosuid,nodev 0 0");
+    Path path = fileSystem.getPath("/usr/local/google/_blaze");
+    FileSystemUtils.createDirectoryAndParents(path);
+    assertEquals("ext3", FileSystemUtils.getFileSystem(path));
+
+    // Should match the root "/"
+    path = fileSystem.getPath("/usr/local/tmp");
+    FileSystemUtils.createDirectoryAndParents(path);
+    assertEquals("ext2", FileSystemUtils.getFileSystem(path));
+
+    // Make sure we don't consider /foobar matches /foo
+    path = fileSystem.getPath("/foo");
+    FileSystemUtils.createDirectoryAndParents(path);
+    assertEquals("dummy_foo", FileSystemUtils.getFileSystem(path));
+    path = fileSystem.getPath("/foobar");
+    FileSystemUtils.createDirectoryAndParents(path);
+    assertEquals("dummy_foobar", FileSystemUtils.getFileSystem(path));
+
+    path = fileSystem.getPath("/dev/shm/blaze");
+    FileSystemUtils.createDirectoryAndParents(path);
+    assertEquals("tmpfs", FileSystemUtils.getFileSystem(path));
+
+    Path fusePath = fileSystem.getPath("/fuse/mnt/tmp");
+    FileSystemUtils.createDirectoryAndParents(fusePath);
+    assertEquals("fuse", FileSystemUtils.getFileSystem(fusePath));
+
+    // Create a symlink and make sure it gives the file system of the symlink target.
+    path = fileSystem.getPath("/usr/local/google/_blaze/out");
+    path.createSymbolicLink(fusePath);
+    assertEquals("fuse", FileSystemUtils.getFileSystem(path));
+
+    // Non existent path should return "unknown"
+    path = fileSystem.getPath("/does/not/exist");
+    assertEquals("unknown", FileSystemUtils.getFileSystem(path));
+  }
+
+  @Test
+  public void testStartsWithAnySuccess() throws Exception {
+    PathFragment a = new PathFragment("a");
+    assertTrue(FileSystemUtils.startsWithAny(a,
+        Arrays.asList(new PathFragment("b"), new PathFragment("a"))));
+  }
+
+  @Test
+  public void testStartsWithAnyNotFound() throws Exception {
+    PathFragment a = new PathFragment("a");
+    assertFalse(FileSystemUtils.startsWithAny(a,
+        Arrays.asList(new PathFragment("b"), new PathFragment("c"))));
+  }
+
+  @Test
+  public void testIterateLines() throws Exception {
+    Path file = fileSystem.getPath("/test.txt");
+    FileSystemUtils.writeContent(file, ISO_8859_1, "a\nb");
+    assertEquals(Arrays.asList("a", "b"),
+        Lists.newArrayList(FileSystemUtils.iterateLinesAsLatin1(file)));
+
+    FileSystemUtils.writeContent(file, ISO_8859_1, "a\rb");
+    assertEquals(Arrays.asList("a", "b"),
+        Lists.newArrayList(FileSystemUtils.iterateLinesAsLatin1(file)));
+
+    FileSystemUtils.writeContent(file, ISO_8859_1, "a\r\nb");
+    assertEquals(Arrays.asList("a", "b"),
+        Lists.newArrayList(FileSystemUtils.iterateLinesAsLatin1(file)));
+  }
+
+  @Test
+  public void testEnsureSymbolicLinkDoesNotMakeUnnecessaryChanges() throws Exception {
+    PathFragment target = new PathFragment("/b");
+    Path file = fileSystem.getPath("/a");
+    file.createSymbolicLink(target);
+    long prevTimeMillis = clock.currentTimeMillis();
+    clock.advanceMillis(1000);
+    FileSystemUtils.ensureSymbolicLink(file, target);
+    long timestamp = file.getLastModifiedTime(Symlinks.NOFOLLOW);
+    assertTrue(timestamp == prevTimeMillis);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/FileSystemsTest.java b/src/test/java/com/google/devtools/build/lib/vfs/FileSystemsTest.java
new file mode 100644
index 0000000..88a000f
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/FileSystemsTest.java
@@ -0,0 +1,48 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+
+import com.google.devtools.build.lib.vfs.util.FileSystems;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * This class handles the tests for the FileSystems class.
+ */
+@RunWith(JUnit4.class)
+public class FileSystemsTest {
+
+  @Test
+  public void testFileSystemsCreatesOnlyOneDefaultNative() {
+    assertSame(FileSystems.initDefaultAsNative(),
+               FileSystems.initDefaultAsNative());
+  }
+
+  @Test
+  public void testFileSystemsCreatesOnlyOneDefaultJavaIo() {
+    assertSame(FileSystems.initDefaultAsJavaIo(),
+               FileSystems.initDefaultAsJavaIo());
+  }
+
+  @Test
+  public void testFileSystemsCanSwitchDefaults() {
+    assertNotSame(FileSystems.initDefaultAsNative(),
+                  FileSystems.initDefaultAsJavaIo());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/GlobTest.java b/src/test/java/com/google/devtools/build/lib/vfs/GlobTest.java
new file mode 100644
index 0000000..37b7dc4
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/GlobTest.java
@@ -0,0 +1,417 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.base.Predicate;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Ordering;
+import com.google.devtools.build.lib.testutil.MoreAsserts;
+import com.google.devtools.build.lib.testutil.TestUtils;
+import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Tests {@link UnixGlob}
+ */
+@RunWith(JUnit4.class)
+public class GlobTest {
+
+  private Path tmpPath;
+  private FileSystem fs;
+  @Before
+  public void setUp() throws Exception {
+    fs = new InMemoryFileSystem();
+    tmpPath = fs.getPath("/globtmp");
+    for (String dir : ImmutableList.of("foo/bar/wiz",
+                         "foo/barnacle/wiz",
+                         "food/barnacle/wiz",
+                         "fool/barnacle/wiz")) {
+      FileSystemUtils.createDirectoryAndParents(tmpPath.getRelative(dir));
+    }
+    FileSystemUtils.createEmptyFile(tmpPath.getRelative("foo/bar/wiz/file"));
+  }
+
+  @Test
+  public void testQuestionMarkMatch() throws Exception {
+    assertGlobMatches("foo?", /* => */"food", "fool");
+  }
+
+  @Test
+  public void testQuestionMarkNoMatch() throws Exception {
+    assertGlobMatches("food/bar?" /* => nothing */);
+  }
+
+  @Test
+  public void testStartsWithStar() throws Exception {
+    assertGlobMatches("*oo", /* => */"foo");
+  }
+
+  @Test
+  public void testStartsWithStarWithMiddleStar() throws Exception {
+    assertGlobMatches("*f*o", /* => */"foo");
+  }
+
+  @Test
+  public void testEndsWithStar() throws Exception {
+    assertGlobMatches("foo*", /* => */"foo", "food", "fool");
+  }
+
+  @Test
+  public void testEndsWithStarWithMiddleStar() throws Exception {
+    assertGlobMatches("f*oo*", /* => */"foo", "food", "fool");
+  }
+
+  @Test
+  public void testMiddleStar() throws Exception {
+    assertGlobMatches("f*o", /* => */"foo");
+  }
+
+  @Test
+  public void testTwoMiddleStars() throws Exception {
+    assertGlobMatches("f*o*o", /* => */"foo");
+  }
+
+  @Test
+  public void testSingleStarPatternWithNamedChild() throws Exception {
+    assertGlobMatches("*/bar", /* => */"foo/bar");
+  }
+
+  @Test
+  public void testSingleStarPatternWithChildGlob() throws Exception {
+    assertGlobMatches("*/bar*", /* => */
+        "foo/bar", "foo/barnacle", "food/barnacle", "fool/barnacle");
+  }
+
+  @Test
+  public void testSingleStarAsChildGlob() throws Exception {
+    assertGlobMatches("foo/*/wiz", /* => */"foo/bar/wiz", "foo/barnacle/wiz");
+  }
+
+  @Test
+  public void testNoAsteriskAndFilesDontExist() throws Exception {
+    // Note un-UNIX like semantics:
+    assertGlobMatches("ceci/n'est/pas/une/globbe" /* => nothing */);
+  }
+
+  @Test
+  public void testSingleAsteriskUnderNonexistentDirectory() throws Exception {
+    // Note un-UNIX like semantics:
+    assertGlobMatches("not-there/*" /* => nothing */);
+  }
+
+  @Test
+  public void testGlobWithNonExistentBase() throws Exception {
+    Collection<Path> globResult = UnixGlob.forPath(fs.getPath("/does/not/exist"))
+        .addPattern("*.txt")
+        .globInterruptible();
+    assertEquals(0, globResult.size());
+  }
+
+  @Test
+  public void testGlobUnderFile() throws Exception {
+    assertGlobMatches("foo/bar/wiz/file/*" /* => nothing */);
+  }
+
+  @Test
+  public void testSingleFileExclude() throws Exception {
+    assertGlobWithExcludeMatches("*", "food", "foo", "fool");
+  }
+
+  @Test
+  public void testExcludeAll() throws Exception {
+    assertGlobWithExcludeMatches("*", "*");
+  }
+
+  @Test
+  public void testExcludeAllButNoMatches() throws Exception {
+    assertGlobWithExcludeMatches("not-there", "*");
+  }
+
+  @Test
+  public void testSingleFileExcludeDoesntMatch() throws Exception {
+    assertGlobWithExcludeMatches("food", "foo", "food");
+  }
+
+  @Test
+  public void testSingleFileExcludeForDirectoryWithChildGlob()
+      throws Exception {
+    assertGlobWithExcludeMatches("foo/*", "foo", "foo/bar", "foo/barnacle");
+  }
+
+  @Test
+  public void testChildGlobWithChildExclude()
+      throws Exception {
+    assertGlobWithExcludeMatches("foo/*", "foo/*");
+    assertGlobWithExcludeMatches("foo/bar", "foo/*");
+    assertGlobWithExcludeMatches("foo/bar", "foo/bar");
+    assertGlobWithExcludeMatches("foo/bar", "*/bar");
+    assertGlobWithExcludeMatches("foo/bar", "*/*");
+    assertGlobWithExcludeMatches("foo/bar/wiz", "*/*/*");
+    assertGlobWithExcludeMatches("foo/bar/wiz", "foo/*/*");
+    assertGlobWithExcludeMatches("foo/bar/wiz", "foo/bar/*");
+    assertGlobWithExcludeMatches("foo/bar/wiz", "foo/bar/wiz");
+    assertGlobWithExcludeMatches("foo/bar/wiz", "*/bar/wiz");
+    assertGlobWithExcludeMatches("foo/bar/wiz", "*/*/wiz");
+    assertGlobWithExcludeMatches("foo/bar/wiz", "foo/*/wiz");
+  }
+
+  private void assertGlobMatches(String pattern, String... expecteds)
+      throws Exception {
+    assertGlobWithExcludesMatches(
+        Collections.singleton(pattern), Collections.<String>emptyList(),
+        expecteds);
+  }
+
+  private void assertGlobMatches(Collection<String> pattern,
+                                 String... expecteds)
+      throws Exception {
+    assertGlobWithExcludesMatches(pattern, Collections.<String>emptyList(),
+        expecteds);
+  }
+
+  private void assertGlobWithExcludeMatches(String pattern, String exclude,
+                                            String... expecteds)
+      throws Exception {
+    assertGlobWithExcludesMatches(
+        Collections.singleton(pattern), Collections.singleton(exclude),
+        expecteds);
+  }
+
+  private void assertGlobWithExcludesMatches(Collection<String> pattern,
+                                             Collection<String> excludes,
+                                             String... expecteds)
+      throws Exception {
+    MoreAsserts.assertSameContents(resolvePaths(expecteds),
+        new UnixGlob.Builder(tmpPath)
+            .addPatterns(pattern)
+            .addExcludes(excludes)
+            .globInterruptible());
+  }
+
+  private Set<Path> resolvePaths(String... relativePaths) {
+    Set<Path> expectedFiles = new HashSet<>();
+    for (String expected : relativePaths) {
+      Path file = expected.equals(".")
+          ? tmpPath
+          : tmpPath.getRelative(expected);
+      expectedFiles.add(file);
+    }
+    return expectedFiles;
+  }
+
+  @Test
+  public void testGlobWithoutWildcardsDoesNotCallReaddir() throws Exception {
+    UnixGlob.FilesystemCalls syscalls = new UnixGlob.FilesystemCalls() {
+      @Override
+      public FileStatus statNullable(Path path, Symlinks symlinks) {
+        return UnixGlob.DEFAULT_SYSCALLS.statNullable(path, symlinks);
+      }
+
+      @Override
+      public Collection<Dirent> readdir(Path path, Symlinks symlinks) {
+        throw new IllegalStateException();
+      }
+    };
+
+    MoreAsserts.assertSameContents(ImmutableList.of(tmpPath.getRelative("foo/bar/wiz/file")),
+        new UnixGlob.Builder(tmpPath)
+            .addPattern("foo/bar/wiz/file")
+            .setFilesystemCalls(new AtomicReference<>(syscalls))
+            .glob());
+  }
+
+  @Test
+  public void testIllegalPatterns() throws Exception {
+    assertIllegalPattern("(illegal) pattern");
+    assertIllegalPattern("[illegal pattern");
+    assertIllegalPattern("}illegal pattern");
+    assertIllegalPattern("foo**bar");
+    assertIllegalPattern("");
+    assertIllegalPattern(".");
+    assertIllegalPattern("/foo");
+    assertIllegalPattern("./foo");
+    assertIllegalPattern("foo/");
+    assertIllegalPattern("foo/./bar");
+    assertIllegalPattern("../foo/bar");
+    assertIllegalPattern("foo//bar");
+  }
+
+  /**
+   * Tests that globs can contain Java regular expression special characters
+   */
+  @Test
+  public void testSpecialRegexCharacter() throws Exception {
+    Path tmpPath2 = fs.getPath("/globtmp2");
+    FileSystemUtils.createDirectoryAndParents(tmpPath2);
+    Path aDotB = tmpPath2.getChild("a.b");
+    FileSystemUtils.createEmptyFile(aDotB);
+    FileSystemUtils.createEmptyFile(tmpPath2.getChild("aab"));
+    // Note: this contains two asterisks because otherwise a RE is not built,
+    // as an optimization.
+    assertThat(UnixGlob.forPath(tmpPath2).addPattern("*a.b*").globInterruptible()).containsExactly(
+        aDotB);
+  }
+
+  @Test
+  public void testMatchesCallWithNoCache() {
+    assertTrue(UnixGlob.matches("*a*b", "CaCb", null));
+  }
+
+  @Test
+  public void testMultiplePatterns() throws Exception {
+    assertGlobMatches(Lists.newArrayList("foo", "fool"), "foo", "fool");
+  }
+
+  @Test
+  public void testMultiplePatternsWithExcludes() throws Exception {
+    assertGlobWithExcludesMatches(Lists.newArrayList("foo", "foo?"),
+        Lists.newArrayList("fool"), "foo", "food");
+  }
+
+  @Test
+  public void testMultiplePatternsWithOverlap() throws Exception {
+    assertGlobMatchesAnyOrder(Lists.newArrayList("food", "foo?"),
+                              "food", "fool");
+    assertGlobMatchesAnyOrder(Lists.newArrayList("food", "?ood", "f??d"),
+                              "food");
+    assertThat(resolvePaths("food", "fool", "foo")).containsExactlyElementsIn(
+        new UnixGlob.Builder(tmpPath).addPatterns("food", "xxx", "*").glob());
+
+  }
+
+  private void assertGlobMatchesAnyOrder(ArrayList<String> patterns,
+                                         String... paths) throws Exception {
+    assertThat(resolvePaths(paths)).containsExactlyElementsIn(
+        new UnixGlob.Builder(tmpPath).addPatterns(patterns).globInterruptible());
+  }
+
+  /**
+   * Tests that a glob returns files in sorted order.
+   */
+  @Test
+  public void testGlobEntriesAreSorted() throws Exception {
+    Collection<Path> directoryEntries = tmpPath.getDirectoryEntries();
+    List<Path> globResult = new UnixGlob.Builder(tmpPath)
+        .addPattern("*")
+        .setExcludeDirectories(false)
+        .globInterruptible();
+    assertThat(Ordering.natural().sortedCopy(directoryEntries)).containsExactlyElementsIn(
+        globResult).inOrder();
+  }
+
+  private void assertIllegalPattern(String pattern) throws Exception {
+    try {
+      new UnixGlob.Builder(tmpPath)
+          .addPattern(pattern)
+          .globInterruptible();
+      fail();
+    } catch (IllegalArgumentException e) {
+      MoreAsserts.assertContainsRegex("in glob pattern", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testHiddenFiles() throws Exception {
+    for (String dir : ImmutableList.of(".hidden", "..also.hidden", "not.hidden")) {
+      FileSystemUtils.createDirectoryAndParents(tmpPath.getRelative(dir));
+    }
+    // Note that these are not in the result: ".", ".."
+    assertGlobMatches("*", "not.hidden", "foo", "fool", "food", ".hidden", "..also.hidden");
+    assertGlobMatches("*.hidden", "not.hidden");
+  }
+
+  @Test
+  public void testCheckCanBeInterrupted() throws Exception {
+    final Thread mainThread = Thread.currentThread();
+    final ThreadPoolExecutor executor = (ThreadPoolExecutor) Executors.newFixedThreadPool(10);
+
+    Predicate<Path> interrupterPredicate = new Predicate<Path>() {
+      @Override
+      public boolean apply(Path input) {
+        mainThread.interrupt();
+        return true;
+      }
+    };
+
+    try {
+      new UnixGlob.Builder(tmpPath)
+          .addPattern("**")
+          .setDirectoryFilter(interrupterPredicate)
+          .setThreadPool(executor)
+          .globInterruptible();
+      fail();  // Should have received InterruptedException
+    } catch (InterruptedException e) {
+      // good
+    }
+
+    assertFalse(executor.isShutdown());
+    executor.shutdown();
+    assertTrue(executor.awaitTermination(TestUtils.WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+  }
+
+  @Test
+  public void testCheckCannotBeInterrupted() throws Exception {
+    final Thread mainThread = Thread.currentThread();
+    final ThreadPoolExecutor executor = (ThreadPoolExecutor) Executors.newFixedThreadPool(10);
+    final AtomicBoolean sentInterrupt = new AtomicBoolean(false);
+
+    Predicate<Path> interrupterPredicate = new Predicate<Path>() {
+      @Override
+      public boolean apply(Path input) {
+        if (!sentInterrupt.getAndSet(true)) {
+          mainThread.interrupt();
+        }
+        return true;
+      }
+    };
+
+    List<Path> result = new UnixGlob.Builder(tmpPath)
+        .addPatterns("**", "*")
+        .setDirectoryFilter(interrupterPredicate).setThreadPool(executor).glob();
+
+    // In the non-interruptible case, the interrupt bit should be set, but the
+    // glob should return the correct set of full results.
+    assertTrue(Thread.interrupted());
+    MoreAsserts.assertSameContents(resolvePaths(".", "foo", "foo/bar", "foo/bar/wiz",
+        "foo/bar/wiz/file", "foo/barnacle", "foo/barnacle/wiz", "food", "food/barnacle",
+        "food/barnacle/wiz", "fool", "fool/barnacle", "fool/barnacle/wiz"), result);
+
+    assertFalse(executor.isShutdown());
+    executor.shutdown();
+    assertTrue(executor.awaitTermination(TestUtils.WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/JavaIoFileSystemTest.java b/src/test/java/com/google/devtools/build/lib/vfs/JavaIoFileSystemTest.java
new file mode 100644
index 0000000..fdb6283
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/JavaIoFileSystemTest.java
@@ -0,0 +1,40 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for the {@link JavaIoFileSystem}. That file system by itself is not
+ * capable of creating symlinks; use the unix one to create them, so that the
+ * test can check that the file system handles their existence correctly.
+ */
+@RunWith(JUnit4.class)
+public class JavaIoFileSystemTest extends SymlinkAwareFileSystemTest {
+
+  @Override
+  public FileSystem getFreshFileSystem() {
+    return new JavaIoFileSystem();
+  }
+
+  // The tests are just inherited from the FileSystemTest
+
+  // JavaIoFileSystem incorrectly throws a FileNotFoundException for all IO errors. This means that
+  // statIfFound incorrectly suppresses those errors.
+  @Override
+  @Test
+  public void testBadPermissionsThrowsExceptionOnStatIfFound() {}
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/ModifiedFileSetTest.java b/src/test/java/com/google/devtools/build/lib/vfs/ModifiedFileSetTest.java
new file mode 100644
index 0000000..96001df
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/ModifiedFileSetTest.java
@@ -0,0 +1,54 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.testing.EqualsTester;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for {@link ModifiedFileSet}.
+ */
+@RunWith(JUnit4.class)
+public class ModifiedFileSetTest {
+
+  @Test
+  public void testHashCodeAndEqualsContract() throws Exception {
+    PathFragment fragA = new PathFragment("a");
+    PathFragment fragB = new PathFragment("b");
+
+    ModifiedFileSet empty1 = ModifiedFileSet.NOTHING_MODIFIED;
+    ModifiedFileSet empty2 = ModifiedFileSet.builder().build();
+    ModifiedFileSet empty3 = ModifiedFileSet.builder().modifyAll(
+        ImmutableList.<PathFragment>of()).build();
+
+    ModifiedFileSet nonEmpty1 = ModifiedFileSet.builder().modifyAll(
+        ImmutableList.of(fragA, fragB)).build();
+    ModifiedFileSet nonEmpty2 = ModifiedFileSet.builder().modifyAll(
+        ImmutableList.of(fragB, fragA)).build();
+    ModifiedFileSet nonEmpty3 = ModifiedFileSet.builder().modify(fragA).modify(fragB).build();
+    ModifiedFileSet nonEmpty4 = ModifiedFileSet.builder().modify(fragB).modify(fragA).build();
+
+    ModifiedFileSet everythingModified = ModifiedFileSet.EVERYTHING_MODIFIED;
+
+    new EqualsTester()
+        .addEqualityGroup(empty1, empty2, empty3)
+        .addEqualityGroup(nonEmpty1, nonEmpty2, nonEmpty3, nonEmpty4)
+        .addEqualityGroup(everythingModified)
+        .testEquals();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/PathFragmentTest.java b/src/test/java/com/google/devtools/build/lib/vfs/PathFragmentTest.java
new file mode 100644
index 0000000..9ab9bfa
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/PathFragmentTest.java
@@ -0,0 +1,481 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.testing.EqualsTester;
+import com.google.devtools.build.lib.testutil.MoreAsserts;
+import com.google.devtools.build.lib.testutil.TestUtils;
+import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.File;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * This class tests the functionality of the PathFragment.
+ */
+@RunWith(JUnit4.class)
+public class PathFragmentTest {
+  @Test
+  public void testMergeFourPathsWithAbsolute() {
+    assertEquals(new PathFragment("x/y/z/a/b/c/d/e"),
+        new PathFragment(new PathFragment("x/y"), new PathFragment("z/a"),
+            new PathFragment("/b/c"), // absolute!
+            new PathFragment("d/e")));
+  }
+
+  @Test
+  public void testEqualsAndHashCode() {
+    InMemoryFileSystem filesystem = new InMemoryFileSystem();
+
+    new EqualsTester()
+        .addEqualityGroup(new PathFragment("../relative/path"),
+                          new PathFragment("../relative/path"),
+                          new PathFragment(new File("../relative/path")))
+        .addEqualityGroup(new PathFragment("something/else"))
+        .addEqualityGroup(new PathFragment("/something/else"))
+        .addEqualityGroup(new PathFragment("/"),
+                          new PathFragment("//////"))
+        .addEqualityGroup(new PathFragment(""))
+        .addEqualityGroup(filesystem.getRootDirectory())  // A Path object.
+        .testEquals();
+  }
+
+  @Test
+  public void testHashCodeCache() {
+    PathFragment relativePath = new PathFragment("../relative/path");
+    PathFragment rootPath = new PathFragment("/");
+
+    int oldResult = relativePath.hashCode();
+    int rootResult = rootPath.hashCode();
+    assertEquals(oldResult, relativePath.hashCode());
+    assertEquals(rootResult, rootPath.hashCode());
+  }
+
+  private void checkRelativeTo(String path, String base) {
+    PathFragment relative = new PathFragment(path).relativeTo(base);
+    assertEquals(new PathFragment(path), new PathFragment(base).getRelative(relative).normalize());
+  }
+
+  @Test
+  public void testRelativeTo() {
+    assertPath("bar/baz", new PathFragment("foo/bar/baz").relativeTo("foo"));
+    assertPath("bar/baz", new PathFragment("/foo/bar/baz").relativeTo("/foo"));
+    assertPath("baz", new PathFragment("foo/bar/baz").relativeTo("foo/bar"));
+    assertPath("baz", new PathFragment("/foo/bar/baz").relativeTo("/foo/bar"));
+    assertPath("foo", new PathFragment("/foo").relativeTo("/"));
+    assertPath("foo", new PathFragment("foo").relativeTo(""));
+    assertPath("foo/bar", new PathFragment("foo/bar").relativeTo(""));
+
+    checkRelativeTo("foo/bar/baz", "foo");
+    checkRelativeTo("/foo/bar/baz", "/foo");
+    checkRelativeTo("foo/bar/baz", "foo/bar");
+    checkRelativeTo("/foo/bar/baz", "/foo/bar");
+    checkRelativeTo("/foo", "/");
+    checkRelativeTo("foo", "");
+    checkRelativeTo("foo/bar", "");
+  }
+
+  @Test
+  public void testIsAbsolute() {
+    assertTrue(new PathFragment("/absolute/test").isAbsolute());
+    assertFalse(new PathFragment("relative/test").isAbsolute());
+    assertTrue(new PathFragment(new File("/absolute/test")).isAbsolute());
+    assertFalse(new PathFragment(new File("relative/test")).isAbsolute());
+  }
+
+  @Test
+  public void testIsNormalized() {
+    assertTrue(new PathFragment("/absolute/path").isNormalized());
+    assertTrue(new PathFragment("some//path").isNormalized());
+    assertFalse(new PathFragment("some/./path").isNormalized());
+    assertFalse(new PathFragment("../some/path").isNormalized());
+    assertFalse(new PathFragment("some/other/../path").isNormalized());
+    assertTrue(new PathFragment("some/other//tricky..path..").isNormalized());
+    assertTrue(new PathFragment("/some/other//tricky..path..").isNormalized());
+  }
+
+  @Test
+  public void testRootNodeReturnsRootString() {
+    PathFragment rootFragment = new PathFragment("/");
+    assertEquals("/", rootFragment.getPathString());
+  }
+
+  @Test
+  public void testGetPathFragmentDoesNotNormalize() {
+    String nonCanonicalPath = "/a/weird/noncanonical/../path/.";
+    assertEquals(nonCanonicalPath,
+        new PathFragment(nonCanonicalPath).getPathString());
+  }
+
+  @Test
+  public void testGetRelative() {
+    assertEquals("a/b", new PathFragment("a").getRelative("b").getPathString());
+    assertEquals("a/b/c/d", new PathFragment("a/b").getRelative("c/d").getPathString());
+    assertEquals("/a/b", new PathFragment("c/d").getRelative("/a/b").getPathString());
+    assertEquals("a", new PathFragment("a").getRelative("").getPathString());
+    assertEquals("/", new PathFragment("/").getRelative("").getPathString());
+  }
+
+  @Test
+  public void testGetChildWorks() {
+    PathFragment pf = new PathFragment("../some/path");
+    assertEquals(new PathFragment("../some/path/hi"), pf.getChild("hi"));
+  }
+
+  @Test
+  public void testGetChildRejectsInvalidBaseNames() {
+    PathFragment pf = new PathFragment("../some/path");
+    assertGetChildFails(pf, ".");
+    assertGetChildFails(pf, "..");
+    assertGetChildFails(pf, "x/y");
+    assertGetChildFails(pf, "/y");
+    assertGetChildFails(pf, "y/");
+    assertGetChildFails(pf, "");
+  }
+
+  private void assertGetChildFails(PathFragment pf, String baseName) {
+    try {
+      pf.getChild(baseName);
+      fail();
+    } catch (Exception e) { /* Expected. */ }
+  }
+
+  // Tests after here test the canonicalization
+  private void assertRegular(String expected, String actual) {
+    assertEquals(expected, new PathFragment(actual).getPathString()); // compare string forms
+    assertEquals(new PathFragment(expected), new PathFragment(actual)); // compare fragment forms
+  }
+
+  @Test
+  public void testEmptyPathToEmptyPath() {
+    assertRegular("/", "/");
+    assertRegular("", "");
+  }
+
+  @Test
+  public void testRedundantSlashes() {
+    assertRegular("/", "///");
+    assertRegular("/foo/bar", "/foo///bar");
+    assertRegular("/foo/bar", "////foo//bar");
+  }
+
+  @Test
+  public void testSimpleNameToSimpleName() {
+    assertRegular("/foo", "/foo");
+    assertRegular("foo", "foo");
+  }
+
+  @Test
+  public void testSimplePathToSimplePath() {
+    assertRegular("/foo/bar", "/foo/bar");
+    assertRegular("foo/bar", "foo/bar");
+  }
+
+  @Test
+  public void testStripsTrailingSlash() {
+    assertRegular("/foo/bar", "/foo/bar/");
+  }
+
+  @Test
+  public void testGetParentDirectory() {
+    PathFragment fooBarWiz = new PathFragment("foo/bar/wiz");
+    PathFragment fooBar = new PathFragment("foo/bar");
+    PathFragment foo = new PathFragment("foo");
+    PathFragment empty = new PathFragment("");
+    assertEquals(fooBar, fooBarWiz.getParentDirectory());
+    assertEquals(foo, fooBar.getParentDirectory());
+    assertEquals(empty, foo.getParentDirectory());
+    assertNull(empty.getParentDirectory());
+
+    PathFragment fooBarWizAbs = new PathFragment("/foo/bar/wiz");
+    PathFragment fooBarAbs = new PathFragment("/foo/bar");
+    PathFragment fooAbs = new PathFragment("/foo");
+    PathFragment rootAbs = new PathFragment("/");
+    assertEquals(fooBarAbs, fooBarWizAbs.getParentDirectory());
+    assertEquals(fooAbs, fooBarAbs.getParentDirectory());
+    assertEquals(rootAbs, fooAbs.getParentDirectory());
+    assertNull(rootAbs.getParentDirectory());
+
+    // Note, this is surprising but correct behavior:
+    assertEquals(fooBarAbs,
+                 new PathFragment("/foo/bar/..").getParentDirectory());
+  }
+  
+  @Test
+  public void testSegmentsCount() {
+    assertEquals(2, new PathFragment("foo/bar").segmentCount());
+    assertEquals(2, new PathFragment("/foo/bar").segmentCount());
+    assertEquals(2, new PathFragment("foo//bar").segmentCount());
+    assertEquals(2, new PathFragment("/foo//bar").segmentCount());
+    assertEquals(1, new PathFragment("foo/").segmentCount());
+    assertEquals(1, new PathFragment("/foo/").segmentCount());
+    assertEquals(1, new PathFragment("foo").segmentCount());
+    assertEquals(1, new PathFragment("/foo").segmentCount());
+    assertEquals(0, new PathFragment("/").segmentCount());
+    assertEquals(0, new PathFragment("").segmentCount());
+  }
+
+
+  @Test
+  public void testGetSegment() {
+    assertEquals("foo", new PathFragment("foo/bar").getSegment(0));
+    assertEquals("bar", new PathFragment("foo/bar").getSegment(1));
+    assertEquals("foo", new PathFragment("/foo/bar").getSegment(0));
+    assertEquals("bar", new PathFragment("/foo/bar").getSegment(1));
+    assertEquals("foo", new PathFragment("foo/").getSegment(0));
+    assertEquals("foo", new PathFragment("/foo/").getSegment(0));
+    assertEquals("foo", new PathFragment("foo").getSegment(0));
+    assertEquals("foo", new PathFragment("/foo").getSegment(0));
+  }
+
+  @Test
+  public void testBasename() throws Exception {
+    assertEquals("bar", new PathFragment("foo/bar").getBaseName());
+    assertEquals("bar", new PathFragment("/foo/bar").getBaseName());
+    assertEquals("foo", new PathFragment("foo/").getBaseName());
+    assertEquals("foo", new PathFragment("/foo/").getBaseName());
+    assertEquals("foo", new PathFragment("foo").getBaseName());
+    assertEquals("foo", new PathFragment("/foo").getBaseName());
+    assertEquals("", new PathFragment("/").getBaseName());
+    assertEquals("", new PathFragment("").getBaseName());
+  }
+
+  private static void assertPath(String expected, PathFragment actual) {
+    assertEquals(expected, actual.getPathString());
+  }
+
+  @Test
+  public void testReplaceName() throws Exception {
+    assertPath("foo/baz", new PathFragment("foo/bar").replaceName("baz"));
+    assertPath("/foo/baz", new PathFragment("/foo/bar").replaceName("baz"));
+    assertPath("foo", new PathFragment("foo/bar").replaceName(""));
+    assertPath("baz", new PathFragment("foo/").replaceName("baz"));
+    assertPath("/baz", new PathFragment("/foo/").replaceName("baz"));
+    assertPath("baz", new PathFragment("foo").replaceName("baz"));
+    assertPath("/baz", new PathFragment("/foo").replaceName("baz"));
+    assertEquals(null, new PathFragment("/").replaceName("baz"));
+    assertEquals(null, new PathFragment("/").replaceName(""));
+    assertEquals(null, new PathFragment("").replaceName("baz"));
+    assertEquals(null, new PathFragment("").replaceName(""));
+
+    assertPath("foo/bar/baz", new PathFragment("foo/bar").replaceName("bar/baz"));
+    assertPath("foo/bar/baz", new PathFragment("foo/bar").replaceName("bar/baz/"));
+
+    // Absolute path arguments will clobber the original path.
+    assertPath("/absolute", new PathFragment("foo/bar").replaceName("/absolute"));
+    assertPath("/", new PathFragment("foo/bar").replaceName("/"));
+  }
+  @Test
+  public void testSubFragment() throws Exception {
+    assertPath("/foo/bar/baz",
+               new PathFragment("/foo/bar/baz").subFragment(0, 3));
+    assertPath("foo/bar/baz",
+               new PathFragment("foo/bar/baz").subFragment(0, 3));
+    assertPath("/foo/bar",
+               new PathFragment("/foo/bar/baz").subFragment(0, 2));
+    assertPath("bar/baz",
+               new PathFragment("/foo/bar/baz").subFragment(1, 3));
+    assertPath("/foo",
+               new PathFragment("/foo/bar/baz").subFragment(0, 1));
+    assertPath("bar",
+               new PathFragment("/foo/bar/baz").subFragment(1, 2));
+    assertPath("baz", new PathFragment("/foo/bar/baz").subFragment(2, 3));
+    assertPath("/", new PathFragment("/foo/bar/baz").subFragment(0, 0));
+    assertPath("", new PathFragment("foo/bar/baz").subFragment(0, 0));
+    assertPath("", new PathFragment("foo/bar/baz").subFragment(1, 1));
+    try {
+      fail("unexpectedly succeeded: " + new PathFragment("foo/bar/baz").subFragment(3, 2));
+    } catch (IndexOutOfBoundsException e) { /* Expected. */ }
+    try {
+      fail("unexpectedly succeeded: " + new PathFragment("foo/bar/baz").subFragment(4, 4));
+    } catch (IndexOutOfBoundsException e) { /* Expected. */ }
+  }
+
+  @Test
+  public void testStartsWith() {
+    PathFragment foobar = new PathFragment("/foo/bar");
+    PathFragment foobarRelative = new PathFragment("foo/bar");
+
+    // (path, prefix) => true
+    assertTrue(foobar.startsWith(foobar));
+    assertTrue(foobar.startsWith(new PathFragment("/")));
+    assertTrue(foobar.startsWith(new PathFragment("/foo")));
+    assertTrue(foobar.startsWith(new PathFragment("/foo/")));
+    assertTrue(foobar.startsWith(new PathFragment("/foo/bar/")));  // Includes trailing slash.
+
+    // (prefix, path) => false
+    assertFalse(new PathFragment("/foo").startsWith(foobar));
+    assertFalse(new PathFragment("/").startsWith(foobar));
+
+    // (absolute, relative) => false
+    assertFalse(foobar.startsWith(foobarRelative));
+    assertFalse(foobarRelative.startsWith(foobar));
+
+    // (relative path, relative prefix) => true
+    assertTrue(foobarRelative.startsWith(foobarRelative));
+    assertTrue(foobarRelative.startsWith(new PathFragment("foo")));
+    assertTrue(foobarRelative.startsWith(new PathFragment("")));
+
+    // (path, sibling) => false
+    assertFalse(new PathFragment("/foo/wiz").startsWith(foobar));
+    assertFalse(foobar.startsWith(new PathFragment("/foo/wiz")));
+
+    // Does not normalize.
+    PathFragment foodotbar = new PathFragment("foo/./bar");
+    assertTrue(foodotbar.startsWith(foodotbar));
+    assertTrue(foodotbar.startsWith(new PathFragment("foo/.")));
+    assertTrue(foodotbar.startsWith(new PathFragment("foo/./")));
+    assertTrue(foodotbar.startsWith(new PathFragment("foo/./bar")));
+    assertFalse(foodotbar.startsWith(new PathFragment("foo/bar")));
+  }
+
+  @Test
+  public void testEndsWith() {
+    PathFragment foobar = new PathFragment("/foo/bar");
+    PathFragment foobarRelative = new PathFragment("foo/bar");
+
+    // (path, suffix) => true
+    assertTrue(foobar.endsWith(foobar));
+    assertTrue(foobar.endsWith(new PathFragment("bar")));
+    assertTrue(foobar.endsWith(new PathFragment("foo/bar")));
+    assertTrue(foobar.endsWith(new PathFragment("/foo/bar")));
+    assertFalse(foobar.endsWith(new PathFragment("/bar")));
+
+    // (prefix, path) => false
+    assertFalse(new PathFragment("/foo").endsWith(foobar));
+    assertFalse(new PathFragment("/").endsWith(foobar));
+
+    // (suffix, path) => false
+    assertFalse(new PathFragment("/bar").endsWith(foobar));
+    assertFalse(new PathFragment("bar").endsWith(foobar));
+    assertFalse(new PathFragment("").endsWith(foobar));
+
+    // (absolute, relative) => true
+    assertTrue(foobar.endsWith(foobarRelative));
+
+    // (relative, absolute) => false
+    assertFalse(foobarRelative.endsWith(foobar));
+
+    // (relative path, relative prefix) => true
+    assertTrue(foobarRelative.endsWith(foobarRelative));
+    assertTrue(foobarRelative.endsWith(new PathFragment("bar")));
+    assertTrue(foobarRelative.endsWith(new PathFragment("")));
+
+    // (path, sibling) => false
+    assertFalse(new PathFragment("/foo/wiz").endsWith(foobar));
+    assertFalse(foobar.endsWith(new PathFragment("/foo/wiz")));
+  }
+
+  static List<PathFragment> toPaths(List<String> strs) {
+    List<PathFragment> paths = Lists.newArrayList();
+    for (String s : strs) {
+      paths.add(new PathFragment(s));
+    }
+    return paths;
+  }
+
+  @Test
+  public void testCompareTo() throws Exception {
+    List<String> pathStrs = ImmutableList.of(
+        "", "/", "//", ".", "/./", "foo/.//bar", "foo", "/foo", "foo/bar", "foo/Bar", "Foo/bar");
+    List<PathFragment> paths = toPaths(pathStrs);
+    // First test that compareTo is self-consistent.
+    for (PathFragment x : paths) {
+      for (PathFragment y : paths) {
+        for (PathFragment z : paths) {
+          // Anti-symmetry
+          assertEquals(Integer.signum(x.compareTo(y)),
+                       -1 * Integer.signum(y.compareTo(x)));
+          // Transitivity
+          if (x.compareTo(y) > 0 && y.compareTo(z) > 0) {
+            MoreAsserts.assertGreaterThan(0, x.compareTo(z));
+          }
+          // "Substitutability"
+          if (x.compareTo(y) == 0) {
+            assertEquals(Integer.signum(x.compareTo(z)), Integer.signum(y.compareTo(z)));
+          }
+          // Consistency with equals
+          assertEquals((x.compareTo(y) == 0), x.equals(y));
+        }
+      }
+    }
+    // Now test that compareTo does what we expect.  The exact ordering here doesn't matter much,
+    // but there are three things to notice: 1. absolute < relative, 2. comparison is lexicographic
+    // 3. repeated slashes are ignored. (PathFragment("//") prints as "/").
+    Collections.shuffle(paths);
+    Collections.sort(paths);
+    List<PathFragment> expectedOrder = toPaths(ImmutableList.of(
+        "/", "//", "/./", "/foo", "", ".", "Foo/bar", "foo", "foo/.//bar", "foo/Bar", "foo/bar"));
+    assertEquals(expectedOrder, paths);
+  }
+
+  @Test
+  public void testGetSafePathString() {
+    assertEquals("/", new PathFragment("/").getSafePathString());
+    assertEquals("/abc", new PathFragment("/abc").getSafePathString());
+    assertEquals(".", new PathFragment("").getSafePathString());
+    assertEquals(".", PathFragment.EMPTY_FRAGMENT.getSafePathString());
+    assertEquals("abc/def", new PathFragment("abc/def").getSafePathString());
+  }
+  
+  @Test
+  public void testNormalize() {
+    assertEquals(new PathFragment("/a/b"), new PathFragment("/a/b").normalize());
+    assertEquals(new PathFragment("/a/b"), new PathFragment("/a/./b").normalize());
+    assertEquals(new PathFragment("/b"), new PathFragment("/a/../b").normalize());
+    assertEquals(new PathFragment("a/b"), new PathFragment("a/b").normalize());
+    assertEquals(new PathFragment("../b"), new PathFragment("a/../../b").normalize());
+    assertEquals(new PathFragment(".."), new PathFragment("a/../..").normalize());
+    assertEquals(new PathFragment("b"), new PathFragment("a/../b").normalize());
+    assertEquals(new PathFragment("a/b"), new PathFragment("a/b/../b").normalize());
+    assertEquals(new PathFragment("/.."), new PathFragment("/..").normalize());
+  }
+
+  @Test
+  public void testSerializationSimple() throws Exception {
+   checkSerialization("a", 91);
+  }
+
+  @Test
+  public void testSerializationAbsolute() throws Exception {
+    checkSerialization("/foo", 94);
+   }
+
+  @Test
+  public void testSerializationNested() throws Exception {
+    checkSerialization("foo/bar/baz", 101);
+  }
+
+  private void checkSerialization(String pathFragmentString, int expectedSize) throws Exception {
+    PathFragment a = new PathFragment(pathFragmentString);
+    byte[] sa = TestUtils.serializeObject(a);
+    assertEquals(expectedSize, sa.length);
+
+    PathFragment a2 = (PathFragment) TestUtils.deserializeObject(sa);
+    assertEquals(a, a2);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/PathFragmentWindowsTest.java b/src/test/java/com/google/devtools/build/lib/vfs/PathFragmentWindowsTest.java
new file mode 100644
index 0000000..43c94d4
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/PathFragmentWindowsTest.java
@@ -0,0 +1,218 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.File;
+
+/**
+ * This class tests the functionality of the PathFragment.
+ */
+@RunWith(JUnit4.class)
+public class PathFragmentWindowsTest {
+  
+  @Test
+  public void testWindowsSeparator() {
+    assertEquals("bar/baz", new PathFragment("bar\\baz").toString());
+    assertEquals("C:/bar/baz", new PathFragment("c:\\bar\\baz").toString());
+  }
+
+  @Test
+  public void testIsAbsoluteWindows() {
+    assertTrue(new PathFragment("C:/").isAbsolute());
+    assertTrue(new PathFragment("C:/").isAbsolute());
+    assertTrue(new PathFragment("C:/foo").isAbsolute());
+    assertTrue(new PathFragment("d:/foo/bar").isAbsolute());
+
+    assertFalse(new PathFragment("*:/").isAbsolute());
+
+    // C: is not an absolute path, it points to the current active directory on drive C:.
+    assertFalse(new PathFragment("C:").isAbsolute());
+    assertFalse(new PathFragment("C:foo").isAbsolute());
+  }
+
+  @Test
+  public void testIsAbsoluteWindowsBackslash() {
+    assertTrue(new PathFragment(new File("C:\\blah")).isAbsolute());
+    assertTrue(new PathFragment(new File("C:\\")).isAbsolute());
+    assertTrue(new PathFragment(new File("\\blah")).isAbsolute());
+    assertTrue(new PathFragment(new File("\\")).isAbsolute());
+  }
+
+  @Test
+  public void testIsNormalizedWindows() {
+    assertTrue(new PathFragment("C:/").isNormalized());
+    assertTrue(new PathFragment("C:/absolute/path").isNormalized());
+    assertFalse(new PathFragment("C:/absolute/./path").isNormalized());
+    assertFalse(new PathFragment("C:/absolute/../path").isNormalized());
+  }
+
+  @Test
+  public void testRootNodeReturnsRootStringWindows() {
+    PathFragment rootFragment = new PathFragment("C:/");
+    assertEquals("C:/", rootFragment.getPathString());
+  }
+
+  @Test
+  public void testGetRelativeWindows() {
+    assertEquals("C:/a/b", new PathFragment("C:/a").getRelative("b").getPathString());
+    assertEquals("C:/a/b/c/d", new PathFragment("C:/a/b").getRelative("c/d").getPathString());
+    assertEquals("C:/b", new PathFragment("C:/a").getRelative("C:/b").getPathString());
+    assertEquals("C:/c/d", new PathFragment("C:/a/b").getRelative("C:/c/d").getPathString());
+    assertEquals("C:/b", new PathFragment("a").getRelative("C:/b").getPathString());
+    assertEquals("C:/c/d", new PathFragment("a/b").getRelative("C:/c/d").getPathString());
+  }
+
+  @Test
+  public void testGetRelativeMixed() {
+    assertEquals("/b", new PathFragment("C:/a").getRelative("/b").getPathString());
+    assertEquals("C:/b", new PathFragment("/a").getRelative("C:/b").getPathString());
+  }
+
+  @Test
+  public void testGetChildWorks() {
+    PathFragment pf = new PathFragment("../some/path");
+    assertEquals(new PathFragment("../some/path/hi"), pf.getChild("hi"));
+  }
+
+  // Tests after here test the canonicalization
+  private void assertRegular(String expected, String actual) {
+    assertEquals(expected, new PathFragment(actual).getPathString()); // compare string forms
+    assertEquals(new PathFragment(expected), new PathFragment(actual)); // compare fragment forms
+  }
+
+  @Test
+  public void testEmptyPathToEmptyPathWindows() {
+    assertRegular("C:/", "C:/");
+  }
+
+  @Test
+  public void testEmptyRelativePathToEmptyPathWindows() {
+    assertRegular("C:", "C:");
+  }
+
+  @Test
+  public void testWindowsVolumeUppercase() {
+    assertRegular("C:/", "c:/");
+  }
+
+  @Test
+  public void testRedundantSlashesWindows() {
+    assertRegular("C:/", "C:///");
+    assertRegular("C:/foo/bar", "C:/foo///bar");
+    assertRegular("C:/foo/bar", "C:////foo//bar");
+  }
+
+  @Test
+  public void testSimpleNameToSimpleNameWindows() {
+    assertRegular("C:/foo", "C:/foo");
+  }
+
+  @Test
+  public void testStripsTrailingSlashWindows() {
+    assertRegular("C:/foo/bar", "C:/foo/bar/");
+  }
+
+  @Test
+  public void testGetParentDirectoryWindows() {
+    PathFragment fooBarWizAbs = new PathFragment("C:/foo/bar/wiz");
+    PathFragment fooBarAbs = new PathFragment("C:/foo/bar");
+    PathFragment fooAbs = new PathFragment("C:/foo");
+    PathFragment rootAbs = new PathFragment("C:/");
+    assertEquals(fooBarAbs, fooBarWizAbs.getParentDirectory());
+    assertEquals(fooAbs, fooBarAbs.getParentDirectory());
+    assertEquals(rootAbs, fooAbs.getParentDirectory());
+    assertNull(rootAbs.getParentDirectory());
+
+    // Note, this is suprising but correct behaviour:
+    assertEquals(fooBarAbs,
+                 new PathFragment("C:/foo/bar/..").getParentDirectory());
+  }
+
+  @Test
+  public void testSegmentsCountWindows() {
+    assertEquals(1, new PathFragment("C:/foo").segmentCount());
+    assertEquals(0, new PathFragment("C:/").segmentCount());
+  }
+
+  @Test
+  public void testGetSegmentWindows() {
+    assertEquals("foo", new PathFragment("C:/foo/bar").getSegment(0));
+    assertEquals("bar", new PathFragment("C:/foo/bar").getSegment(1));
+    assertEquals("foo", new PathFragment("C:/foo/").getSegment(0));
+    assertEquals("foo", new PathFragment("C:/foo").getSegment(0));
+  }
+
+  @Test
+  public void testBasenameWindows() throws Exception {
+    assertEquals("bar", new PathFragment("C:/foo/bar").getBaseName());
+    assertEquals("foo", new PathFragment("C:/foo").getBaseName());
+    // Never return the drive name as a basename.
+    assertEquals("", new PathFragment("C:/").getBaseName());
+  }
+
+  private static void assertPath(String expected, PathFragment actual) {
+    assertEquals(expected, actual.getPathString());
+  }
+
+  @Test
+  public void testReplaceNameWindows() throws Exception {
+    assertPath("C:/foo/baz", new PathFragment("C:/foo/bar").replaceName("baz"));
+    assertEquals(null, new PathFragment("C:/").replaceName("baz"));
+  }
+
+  @Test
+  public void testStartsWithWindows() {
+    assertTrue(new PathFragment("C:/foo/bar").startsWith(new PathFragment("C:/foo")));
+    assertTrue(new PathFragment("C:/foo/bar").startsWith(new PathFragment("C:/")));
+    assertTrue(new PathFragment("C:foo/bar").startsWith(new PathFragment("C:")));
+    assertTrue(new PathFragment("C:/").startsWith(new PathFragment("C:/")));
+    assertTrue(new PathFragment("C:").startsWith(new PathFragment("C:")));
+
+    // The first path is absolute, the second is not.
+    assertFalse(new PathFragment("C:/foo/bar").startsWith(new PathFragment("C:")));
+    assertFalse(new PathFragment("C:/").startsWith(new PathFragment("C:")));
+  }
+
+  @Test
+  public void testEndsWithWindows() {
+    assertTrue(new PathFragment("C:/foo/bar").endsWith(new PathFragment("bar")));
+    assertTrue(new PathFragment("C:/foo/bar").endsWith(new PathFragment("foo/bar")));
+    assertTrue(new PathFragment("C:/foo/bar").endsWith(new PathFragment("C:/foo/bar")));
+    assertTrue(new PathFragment("C:/").endsWith(new PathFragment("C:/")));
+  }
+
+  @Test
+  public void testGetSafePathStringWindows() {
+    assertEquals("C:/", new PathFragment("C:/").getSafePathString());
+    assertEquals("C:/abc", new PathFragment("C:/abc").getSafePathString());
+    assertEquals("C:/abc/def", new PathFragment("C:/abc/def").getSafePathString());
+  }
+
+  @Test
+  public void testNormalizeWindows() {
+    assertEquals(new PathFragment("C:/a/b"), new PathFragment("C:/a/b").normalize());
+    assertEquals(new PathFragment("C:/a/b"), new PathFragment("C:/a/./b").normalize());
+    assertEquals(new PathFragment("C:/b"), new PathFragment("C:/a/../b").normalize());
+    assertEquals(new PathFragment("C:/../b"), new PathFragment("C:/../b").normalize());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/PathTest.java b/src/test/java/com/google/devtools/build/lib/vfs/PathTest.java
new file mode 100644
index 0000000..738e454
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/PathTest.java
@@ -0,0 +1,312 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.Lists;
+import com.google.common.testing.EqualsTester;
+import com.google.common.testing.GcFinalization;
+import com.google.devtools.build.lib.util.BlazeClock;
+import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.lang.ref.WeakReference;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A test for {@link Path}.
+ */
+@RunWith(JUnit4.class)
+public class PathTest {
+  private FileSystem filesystem;
+  private Path root;
+
+  @Before
+  public void setUp() throws Exception {
+    filesystem = new InMemoryFileSystem(BlazeClock.instance());
+    root = filesystem.getRootDirectory();
+    Path first = root.getChild("first");
+    first.createDirectory();
+  }
+
+  @Test
+  public void testStartsWithWorksForSelf() {
+    assertStartsWithReturns(true, "/first/child", "/first/child");
+  }
+
+  @Test
+  public void testStartsWithWorksForChild() {
+    assertStartsWithReturns(true,
+        "/first/child", "/first/child/grandchild");
+  }
+
+  @Test
+  public void testStartsWithWorksForDeepDescendant() {
+    assertStartsWithReturns(true,
+        "/first/child", "/first/child/grandchild/x/y/z");
+  }
+
+  @Test
+  public void testStartsWithFailsForParent() {
+    assertStartsWithReturns(false, "/first/child", "/first");
+  }
+
+  @Test
+  public void testStartsWithFailsForSibling() {
+    assertStartsWithReturns(false, "/first/child", "/first/child2");
+  }
+
+  @Test
+  public void testStartsWithFailsForLinkToDescendant()
+      throws Exception {
+    Path linkTarget = filesystem.getPath("/first/linked_to");
+    FileSystemUtils.createEmptyFile(linkTarget);
+    Path second = filesystem.getPath("/second/");
+    second.createDirectory();
+    second.getChild("child_link").createSymbolicLink(linkTarget);
+    assertStartsWithReturns(false, "/first", "/second/child_link");
+  }
+
+  @Test
+  public void testStartsWithFailsForNullPrefix() {
+    try {
+      filesystem.getPath("/first").startsWith(null);
+      fail();
+    } catch (Exception e) {
+    }
+  }
+
+  private void assertStartsWithReturns(boolean expected,
+                                       String ancestor,
+                                       String descendant) {
+    Path parent = filesystem.getPath(ancestor);
+    Path child = filesystem.getPath(descendant);
+    assertEquals(expected, child.startsWith(parent));
+  }
+
+  @Test
+  public void testGetChildWorks() {
+    assertGetChildWorks("second");
+    assertGetChildWorks("...");
+    assertGetChildWorks("....");
+  }
+
+  private void assertGetChildWorks(String childName) {
+    assertEquals(filesystem.getPath("/first/" + childName),
+        filesystem.getPath("/first").getChild(childName));
+  }
+
+  @Test
+  public void testGetChildFailsForChildWithSlashes() {
+    assertGetChildFails("second/third");
+    assertGetChildFails("./third");
+    assertGetChildFails("../third");
+    assertGetChildFails("second/..");
+    assertGetChildFails("second/.");
+    assertGetChildFails("/third");
+    assertGetChildFails("third/");
+  }
+
+  private void assertGetChildFails(String childName) {
+    try {
+      filesystem.getPath("/first").getChild(childName);
+      fail();
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void testGetChildFailsForDotAndDotDot() {
+    assertGetChildFails(".");
+    assertGetChildFails("..");
+  }
+
+  @Test
+  public void testGetChildFailsForEmptyString() {
+    assertGetChildFails("");
+  }
+
+  @Test
+  public void testRelativeToWorks() {
+    assertRelativeToWorks("apple", "/fruit/apple", "/fruit");
+    assertRelativeToWorks("apple/jonagold", "/fruit/apple/jonagold", "/fruit");
+  }
+
+  @Test
+  public void testGetRelativeWithStringWorks() {
+    assertGetRelativeWorks("/first/x/y", "y");
+    assertGetRelativeWorks("/y", "/y");
+    assertGetRelativeWorks("/first/x/x", "./x");
+    assertGetRelativeWorks("/first/y", "../y");
+    assertGetRelativeWorks("/", "../../../../..");
+  }
+
+  @Test
+  public void testAsFragmentWorks() {
+    assertAsFragmentWorks("/");
+    assertAsFragmentWorks("//");
+    assertAsFragmentWorks("/first");
+    assertAsFragmentWorks("/first/x/y");
+    assertAsFragmentWorks("/first/x/y.foo");
+  }
+
+  @Test
+  public void testGetRelativeWithFragmentWorks() {
+    Path dir = filesystem.getPath("/first/x");
+    assertEquals("/first/x/y",
+                 dir.getRelative(new PathFragment("y")).toString());
+    assertEquals("/first/x/x",
+                 dir.getRelative(new PathFragment("./x")).toString());
+    assertEquals("/first/y",
+                 dir.getRelative(new PathFragment("../y")).toString());
+
+  }
+
+  @Test
+  public void testGetRelativeWithAbsoluteFragmentWorks() {
+    Path root = filesystem.getPath("/first/x");
+    assertEquals("/x/y",
+                 root.getRelative(new PathFragment("/x/y")).toString());
+  }
+
+  @Test
+  public void testGetRelativeWithAbsoluteStringWorks() {
+    Path root = filesystem.getPath("/first/x");
+    assertEquals("/x/y", root.getRelative("/x/y").toString());
+  }
+
+  @Test
+  public void testComparableSortOrder() {
+    Path zzz = filesystem.getPath("/zzz");
+    Path ZZZ = filesystem.getPath("/ZZZ");
+    Path abc = filesystem.getPath("/abc");
+    Path aBc = filesystem.getPath("/aBc");
+    Path AbC = filesystem.getPath("/AbC");
+    Path ABC = filesystem.getPath("/ABC");
+    List<Path> list = Lists.newArrayList(zzz, ZZZ, ABC, aBc, AbC, abc);
+    Collections.sort(list);
+    assertThat(list).containsExactly(ABC, AbC, ZZZ, aBc, abc, zzz).inOrder();
+  }
+
+  @Test
+  public void testParentOfRootIsRoot() {
+    assertSame(root, root.getRelative(".."));
+
+    assertSame(root.getRelative("dots"),
+               root.getRelative("broken/../../dots"));
+  }
+
+  @Test
+  public void testSingleSegmentEquivalence() {
+    assertSame(
+        root.getRelative("aSingleSegment"),
+        root.getRelative("aSingleSegment"));
+  }
+
+  @Test
+  public void testSiblingNonEquivalenceString() {
+    assertNotSame(
+        root.getRelative("aSingleSegment"),
+        root.getRelative("aDifferentSegment"));
+  }
+
+  @Test
+  public void testSiblingNonEquivalenceFragment() {
+    assertNotSame(
+        root.getRelative(new PathFragment("aSingleSegment")),
+        root.getRelative(new PathFragment("aDifferentSegment")));
+  }
+
+  @Test
+  public void testHashCodeStableAcrossGarbageCollections() {
+    Path parent = filesystem.getPath("/a");
+    PathFragment childFragment = new PathFragment("b");
+    Path child = parent.getRelative(childFragment);
+    WeakReference<Path> childRef = new WeakReference<>(child);
+    int childHashCode1 = childRef.get().hashCode();
+    assertEquals(childHashCode1, parent.getRelative(childFragment).hashCode());
+    child = null;
+    GcFinalization.awaitClear(childRef);
+    int childHashCode2 = parent.getRelative(childFragment).hashCode();
+    assertEquals(childHashCode1, childHashCode2);
+  }
+
+  @Test
+  public void testSerialization() throws Exception {
+    FileSystem oldFileSystem = Path.getFileSystemForSerialization();
+    try {
+      Path.setFileSystemForSerialization(filesystem);
+      Path root = filesystem.getPath("/");
+      Path p1 = filesystem.getPath("/foo");
+      Path p2 = filesystem.getPath("/foo/bar");
+
+      ByteArrayOutputStream bos = new ByteArrayOutputStream();
+      ObjectOutputStream oos = new ObjectOutputStream(bos);
+
+      oos.writeObject(root);
+      oos.writeObject(p1);
+      oos.writeObject(p2);
+
+      ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
+      ObjectInputStream ois = new ObjectInputStream(bis);
+
+      Path dsRoot = (Path) ois.readObject();
+      Path dsP1 = (Path) ois.readObject();
+      Path dsP2 = (Path) ois.readObject();
+
+      new EqualsTester()
+          .addEqualityGroup(root, dsRoot)
+          .addEqualityGroup(p1, dsP1)
+          .addEqualityGroup(p2, dsP2)
+          .testEquals();
+
+      assertTrue(p2.startsWith(p1));
+      assertTrue(p2.startsWith(dsP1));
+      assertTrue(dsP2.startsWith(p1));
+      assertTrue(dsP2.startsWith(dsP1));
+    } finally {
+      Path.setFileSystemForSerialization(oldFileSystem);
+    }
+  }
+
+  private void assertAsFragmentWorks(String expected) {
+    assertEquals(new PathFragment(expected), filesystem.getPath(expected).asFragment());
+  }
+
+  private void assertGetRelativeWorks(String expected, String relative) {
+    assertEquals(filesystem.getPath(expected),
+        filesystem.getPath("/first/x").getRelative(relative));
+  }
+
+  private void assertRelativeToWorks(String expected, String relative, String original) {
+    assertEquals(new PathFragment(expected),
+                 filesystem.getPath(relative).relativeTo(filesystem.getPath(original)));
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/PathWindowsTest.java b/src/test/java/com/google/devtools/build/lib/vfs/PathWindowsTest.java
new file mode 100644
index 0000000..c92fc2b
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/PathWindowsTest.java
@@ -0,0 +1,98 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+
+import com.google.devtools.build.lib.util.BlazeClock;
+import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * A test for windows aspects of {@link Path}.
+ */
+@RunWith(JUnit4.class)
+public class PathWindowsTest {
+  private FileSystem filesystem;
+  private Path root;
+
+  @Before
+  public void setUp() throws Exception {
+    filesystem = new InMemoryFileSystem(BlazeClock.instance());
+    root = filesystem.getRootDirectory();
+    Path first = root.getChild("first");
+    first.createDirectory();
+  }
+
+  private void assertAsFragmentWorks(String expected) {
+    assertEquals(new PathFragment(expected), filesystem.getPath(expected).asFragment());
+  }
+
+  @Test
+  public void testWindowsPath() {
+    Path p = filesystem.getPath("C:/foo/bar");
+    assertEquals("C:/foo/bar", p.getPathString());
+    assertEquals("C:/foo/bar", p.toString());
+  }
+
+  @Test
+  public void testAsFragmentWindows() {
+    assertAsFragmentWorks("C:/");
+    assertAsFragmentWorks("C://");
+    assertAsFragmentWorks("C:/first");
+    assertAsFragmentWorks("C:/first/x/y");
+    assertAsFragmentWorks("C:/first/x/y.foo");
+  }
+
+  @Test
+  public void testGetRelativeWithFragmentWindows() {
+    Path dir = filesystem.getPath("C:/first/x");
+    assertEquals("C:/first/x/y",
+                 dir.getRelative(new PathFragment("y")).toString());
+    assertEquals("C:/first/x/x",
+                 dir.getRelative(new PathFragment("./x")).toString());
+    assertEquals("C:/first/y",
+                 dir.getRelative(new PathFragment("../y")).toString());
+    assertEquals("C:/first/y",
+        dir.getRelative(new PathFragment("../y")).toString());
+    assertEquals("C:/y",
+        dir.getRelative(new PathFragment("../../../y")).toString());
+  }
+
+  @Test
+  public void testGetRelativeWithAbsoluteFragmentWindows() {
+    Path root = filesystem.getPath("C:/first/x");
+    assertEquals("C:/x/y",
+                 root.getRelative(new PathFragment("C:/x/y")).toString());
+  }
+
+  @Test
+  public void testGetRelativeWithAbsoluteStringWorksWindows() {
+    Path root = filesystem.getPath("C:/first/x");
+    assertEquals("C:/x/y", root.getRelative("C:/x/y").toString());
+  }
+
+  @Test
+  public void testParentOfRootIsRootWindows() {
+    assertSame(root, root.getRelative(".."));
+
+    assertSame(root.getRelative("dots"),
+               root.getRelative("broken/../../dots"));
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/RecursiveGlobTest.java b/src/test/java/com/google/devtools/build/lib/vfs/RecursiveGlobTest.java
new file mode 100644
index 0000000..5e0012a
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/RecursiveGlobTest.java
@@ -0,0 +1,227 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.build.lib.testutil.MoreAsserts.assertSameContents;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Ordering;
+import com.google.devtools.build.lib.testutil.MoreAsserts;
+import com.google.devtools.build.lib.util.BlazeClock;
+import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Tests {@link UnixGlob} recursive globs.
+ */
+@RunWith(JUnit4.class)
+public class RecursiveGlobTest {
+
+  private Path tmpPath;
+  private FileSystem fileSystem;
+  
+  @Before
+  public void setUp() throws Exception {
+    fileSystem = new InMemoryFileSystem(BlazeClock.instance());
+    tmpPath = fileSystem.getPath("/rglobtmp");
+    for (String dir : ImmutableList.of("foo/bar/wiz",
+                         "foo/baz/wiz",
+                         "foo/baz/quip/wiz",
+                         "food/baz/wiz",
+                         "fool/baz/wiz")) {
+      FileSystemUtils.createDirectoryAndParents(tmpPath.getRelative(dir));
+    }
+    FileSystemUtils.createEmptyFile(tmpPath.getRelative("foo/bar/wiz/file"));
+  }
+
+  @Test
+  public void testDoubleStar() throws Exception {
+    assertGlobMatches("**", ".", "foo", "foo/bar", "foo/bar/wiz", "foo/baz", "foo/baz/quip",
+                      "foo/baz/quip/wiz", "foo/baz/wiz", "foo/bar/wiz/file", "food", "food/baz",
+                      "food/baz/wiz", "fool", "fool/baz", "fool/baz/wiz");
+  }
+
+  @Test
+  public void testDoubleDoubleStar() throws Exception {
+    assertGlobMatches("**/**", ".", "foo", "foo/bar", "foo/bar/wiz", "foo/baz", "foo/baz/quip",
+                      "foo/baz/quip/wiz", "foo/baz/wiz", "foo/bar/wiz/file", "food", "food/baz",
+                      "food/baz/wiz", "fool", "fool/baz", "fool/baz/wiz");
+  }
+
+  @Test
+  public void testDirectoryWithDoubleStar() throws Exception {
+    assertGlobMatches("foo/**", "foo", "foo/bar", "foo/bar/wiz", "foo/baz", "foo/baz/quip",
+                      "foo/baz/quip/wiz", "foo/baz/wiz", "foo/bar/wiz/file");
+  }
+
+  @Test
+  public void testIllegalPatterns() throws Exception {
+    for (String prefix : Lists.newArrayList("", "*/", "**/", "ba/")) {
+      String suffix = ("/" + prefix).substring(0, prefix.length());
+      for (String pattern : Lists.newArrayList("**fo", "fo**", "**fo**", "fo**fo", "fo**fo**fo")) {
+        assertIllegalWildcard(prefix + pattern);
+        assertIllegalWildcard(pattern + suffix);
+        assertIllegalWildcard("foo", pattern + suffix);
+      }
+    }
+  }
+
+  @Test
+  public void testDoubleStarPatternWithNamedChild() throws Exception {
+    assertGlobMatches("**/bar", "foo/bar");
+  }
+
+  @Test
+  public void testDoubleStarPatternWithChildGlob() throws Exception {
+    assertGlobMatches("**/ba*",
+        "foo/bar", "foo/baz", "food/baz", "fool/baz");
+  }
+
+  @Test
+  public void testDoubleStarAsChildGlob() throws Exception {
+    assertGlobMatches("foo/**/wiz", "foo/bar/wiz", "foo/baz/quip/wiz", "foo/baz/wiz");
+  }
+
+  @Test
+  public void testDoubleStarUnderNonexistentDirectory() throws Exception {
+    assertGlobMatches("not-there/**" /* => nothing */);
+  }
+
+  @Test
+  public void testDoubleStarGlobWithNonExistentBase() throws Exception {
+    Collection<Path> globResult = UnixGlob.forPath(fileSystem.getPath("/does/not/exist"))
+        .addPattern("**")
+        .globInterruptible();
+    assertEquals(0, globResult.size());
+  }
+
+  @Test
+  public void testDoubleStarUnderFile() throws Exception {
+    assertGlobMatches("foo/bar/wiz/file/**" /* => nothing */);
+  }
+
+  @Test
+  public void testSingleFileExclude() throws Exception {
+    assertGlobWithExcludeMatches("**", "food", ".", "foo", "foo/bar", "foo/bar/wiz", "foo/baz",
+                                 "foo/baz/quip", "foo/baz/quip/wiz", "foo/baz/wiz",
+                                 "foo/bar/wiz/file", "food/baz", "food/baz/wiz", "fool", "fool/baz",
+                                 "fool/baz/wiz");
+  }
+
+  @Test
+  public void testSingleFileExcludeForDirectoryWithChildGlob()
+      throws Exception {
+    assertGlobWithExcludeMatches("foo/**", "foo", "foo/bar", "foo/bar/wiz", "foo/baz",
+                                 "foo/baz/quip", "foo/baz/quip/wiz", "foo/baz/wiz",
+                                 "foo/bar/wiz/file");
+  }
+
+  @Test
+  public void testGlobExcludeForDirectoryWithChildGlob()
+      throws Exception {
+    assertGlobWithExcludeMatches("foo/**", "foo/*", "foo", "foo/bar/wiz", "foo/baz/quip",
+                                 "foo/baz/quip/wiz", "foo/baz/wiz", "foo/bar/wiz/file");
+  }
+
+  @Test
+  public void testExcludeAll() throws Exception {
+    assertGlobWithExcludesMatches(Lists.newArrayList("**"),
+                                  Lists.newArrayList("*", "*/*", "*/*/*", "*/*/*/*"), ".");
+  }
+
+  @Test
+  public void testManualGlobExcludeForDirectoryWithChildGlob()
+      throws Exception {
+    assertGlobWithExcludesMatches(Lists.newArrayList("foo/**"),
+                                  Lists.newArrayList("foo", "foo/*", "foo/*/*", "foo/*/*/*"));
+  }
+
+  private void assertGlobMatches(String pattern, String... expecteds)
+      throws Exception {
+    assertGlobWithExcludesMatches(
+        Collections.singleton(pattern), Collections.<String>emptyList(),
+        expecteds);
+  }
+
+  private void assertGlobWithExcludeMatches(String pattern, String exclude,
+                                            String... expecteds)
+      throws Exception {
+    assertGlobWithExcludesMatches(
+        Collections.singleton(pattern), Collections.singleton(exclude),
+        expecteds);
+  }
+
+  private void assertGlobWithExcludesMatches(Collection<String> pattern,
+                                             Collection<String> excludes,
+                                             String... expecteds) throws Exception {
+    assertSameContents(resolvePaths(expecteds),
+        new UnixGlob.Builder(tmpPath)
+            .addPatterns(pattern)
+            .addExcludes(excludes)
+            .globInterruptible());
+  }
+
+  private Set<Path> resolvePaths(String... relativePaths) {
+    Set<Path> expectedFiles = new HashSet<>();
+    for (String expected : relativePaths) {
+      Path file = expected.equals(".")
+          ? tmpPath
+          : tmpPath.getRelative(expected);
+      expectedFiles.add(file);
+    }
+    return expectedFiles;
+  }
+
+  /**
+   * Tests that a recursive glob returns files in sorted order.
+   */
+  @Test
+  public void testGlobEntriesAreSorted() throws Exception {
+    List<Path> globResult = new UnixGlob.Builder(tmpPath)
+        .addPattern("**")
+        .setExcludeDirectories(false)
+        .globInterruptible();
+
+    assertThat(Ordering.natural().sortedCopy(globResult)).containsExactlyElementsIn(globResult)
+        .inOrder();
+  }
+
+  private void assertIllegalWildcard(String pattern, String... excludePatterns)
+      throws Exception {
+    try {
+      new UnixGlob.Builder(tmpPath)
+          .addPattern(pattern)
+          .addExcludes(excludePatterns)
+          .globInterruptible();
+      fail();
+    } catch (IllegalArgumentException e) {
+      MoreAsserts.assertContainsRegex("recursive wildcard must be its own segment", e.getMessage());
+    }
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/RootedPathTest.java b/src/test/java/com/google/devtools/build/lib/vfs/RootedPathTest.java
new file mode 100644
index 0000000..46d286d
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/RootedPathTest.java
@@ -0,0 +1,56 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import com.google.common.testing.EqualsTester;
+import com.google.devtools.build.lib.util.BlazeClock;
+import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for {@link RootedPath}.
+ */
+@RunWith(JUnit4.class)
+public class RootedPathTest {
+  private FileSystem filesystem;
+  private Path root;
+
+  @Before
+  public void setUp() throws Exception {
+    filesystem = new InMemoryFileSystem(BlazeClock.instance());
+    root = filesystem.getRootDirectory();
+  }
+
+  @Test
+  public void testEqualsAndHashCodeContract() throws Exception {
+    Path pkgRoot1 = root.getRelative("pkgroot1");
+    Path pkgRoot2 = root.getRelative("pkgroot2");
+    RootedPath rootedPathA1 = RootedPath.toRootedPath(pkgRoot1, new PathFragment("foo/bar"));
+    RootedPath rootedPathA2 = RootedPath.toRootedPath(pkgRoot1, new PathFragment("foo/bar"));
+    RootedPath absolutePath1 = RootedPath.toRootedPath(root, new PathFragment("pkgroot1/foo/bar"));
+    RootedPath rootedPathB1 = RootedPath.toRootedPath(pkgRoot2, new PathFragment("foo/bar"));
+    RootedPath rootedPathB2 = RootedPath.toRootedPath(pkgRoot2, new PathFragment("foo/bar"));
+    RootedPath absolutePath2 = RootedPath.toRootedPath(root, new PathFragment("pkgroot2/foo/bar"));
+    new EqualsTester()
+      .addEqualityGroup(rootedPathA1, rootedPathA2)
+      .addEqualityGroup(rootedPathB1, rootedPathB2)
+      .addEqualityGroup(absolutePath1)
+      .addEqualityGroup(absolutePath2)
+      .testEquals();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/ScopeEscapableFileSystemTest.java b/src/test/java/com/google/devtools/build/lib/vfs/ScopeEscapableFileSystemTest.java
new file mode 100644
index 0000000..6c8071f
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/ScopeEscapableFileSystemTest.java
@@ -0,0 +1,806 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Collection;
+
+/**
+ * Generic tests for any file system that implements {@link ScopeEscapableFileSystem},
+ * i.e. any file system that supports symlinks that escape its scope.
+ *
+ * Each suitable file system test should inherit from this class, thereby obtaining
+ * all the tests.
+ */
+public abstract class ScopeEscapableFileSystemTest extends SymlinkAwareFileSystemTest {
+
+  /**
+   * Trivial FileSystem implementation that can record the last path passed to each method
+   * and read/write to a unified "state" variable (which can then be checked by tests) for
+   * each data type this class manipulates.
+   *
+   * The default implementation of each method throws an exception. Each test case should
+   * selectively override the methods it expects to be invoked.
+   */
+  private static class TestDelegator extends FileSystem {
+    protected Path lastPath;
+    protected boolean booleanState;
+    protected long longState;
+    protected Object objectState;
+
+    public void setState(boolean state) { booleanState = state; }
+    public void setState(long state) { longState = state; }
+    public void setState(Object state) { objectState = state; }
+
+    public boolean booleanState() { return booleanState; }
+    public long longState() { return longState; }
+    public Object objectState() { return objectState; }
+
+    public PathFragment lastPath() {
+      Path ans = lastPath;
+      // Clear this out to protect against accidental matches when testing the same path multiple
+      // consecutive times.
+      lastPath = null;
+      return ans != null ? ans.asFragment() : null;
+    }
+
+    @Override public boolean supportsModifications() { return true; }
+    @Override public boolean supportsSymbolicLinks() { return true; }
+
+    private static RuntimeException re() {
+      return new RuntimeException("This method should not be called in this context");
+    }
+
+    @Override protected boolean isReadable(Path path) { throw re(); }
+    @Override protected boolean isWritable(Path path) { throw re(); }
+    @Override protected boolean isDirectory(Path path, boolean followSymlinks) { throw re(); }
+    @Override protected boolean isFile(Path path, boolean followSymlinks) { throw re(); }
+    @Override protected boolean isExecutable(Path path) { throw re(); }
+    @Override protected boolean exists(Path path, boolean followSymlinks) {throw re(); }
+    @Override protected boolean isSymbolicLink(Path path) { throw re(); }
+    @Override protected boolean createDirectory(Path path) { throw re(); }
+    @Override protected boolean delete(Path path) { throw re(); }
+
+    @Override protected long getFileSize(Path path, boolean followSymlinks) { throw re(); }
+    @Override protected long getLastModifiedTime(Path path, boolean followSymlinks) { throw re(); }
+
+    @Override protected void setWritable(Path path, boolean writable) { throw re(); }
+    @Override protected void setExecutable(Path path, boolean executable) { throw re(); }
+    @Override protected void setReadable(Path path, boolean readable) { throw re(); }
+    @Override protected void setLastModifiedTime(Path path, long newTime) { throw re(); }
+    @Override protected void renameTo(Path sourcePath, Path targetPath) { throw re(); }
+    @Override protected void createSymbolicLink(Path linkPath, PathFragment targetFragment) {
+      throw re();
+    }
+
+    @Override protected PathFragment readSymbolicLink(Path path) { throw re(); }
+    @Override protected InputStream getInputStream(Path path) { throw re(); }
+    @Override protected Collection<Path> getDirectoryEntries(Path path) { throw re(); }
+    @Override protected OutputStream getOutputStream(Path path, boolean append)  { throw re(); }
+    @Override
+    protected FileStatus statIfFound(Path path, boolean followSymlinks) throws IOException {
+      throw re();
+    }
+  }
+
+  protected static final PathFragment SCOPE_ROOT = new PathFragment("/fs/root");
+
+  private Path fileLink;
+  private PathFragment fileLinkTarget;
+  private Path dirLink;
+  private PathFragment dirLinkTarget;
+
+  @Override
+  @Before
+  public void setUp() throws Exception {
+    super.setUp();
+
+    Preconditions.checkState(testFS instanceof ScopeEscapableFileSystem,
+        "Not ScopeEscapable: " + testFS);
+    ((ScopeEscapableFileSystem) testFS).enableScopeChecking(false);
+    for (int i = 1; i <= SCOPE_ROOT.segmentCount(); i++) {
+      testFS.getPath(SCOPE_ROOT.subFragment(0, i)).createDirectory();
+    }
+
+    fileLink = testFS.getPath(SCOPE_ROOT.getRelative("link"));
+    fileLinkTarget = new PathFragment("/should/be/delegated/fileLinkTarget");
+    testFS.createSymbolicLink(fileLink, fileLinkTarget);
+
+    dirLink = testFS.getPath(SCOPE_ROOT.getRelative("dirlink"));
+    dirLinkTarget = new PathFragment("/should/be/delegated/dirLinkTarget");
+    testFS.createSymbolicLink(dirLink, dirLinkTarget);
+  }
+
+  /**
+   * Returns the file system supplied by {@link #getFreshFileSystem}, cast to
+   * a {@link ScopeEscapableFileSystem}. Also enables scope checking within
+   * the file system (which we keep disabled for inherited tests that aren't
+   * intended to test scope boundaries).
+   */
+  private ScopeEscapableFileSystem scopedFS() {
+    ScopeEscapableFileSystem fs = (ScopeEscapableFileSystem) testFS;
+    fs.enableScopeChecking(true);
+    return fs;
+  }
+
+  // Checks that the semi-resolved path passed to the delegator matches the expected value.
+  private void checkPath(TestDelegator delegator, PathFragment expectedDelegatedPath) {
+    assertTrue(expectedDelegatedPath.equals(delegator.lastPath()));
+  }
+
+  // Asserts that the condition is false and checks that the expected path was delegated.
+  private void assertFalseWithPathCheck(boolean result, TestDelegator delegator,
+      PathFragment expectedDelegatedPath) {
+    assertFalse(result);
+    checkPath(delegator, expectedDelegatedPath);
+  }
+
+  // Asserts that the condition is true and checks that the expected path was delegated.
+  private void assertTrueWithPathCheck(boolean result, TestDelegator delegator,
+      PathFragment expectedDelegatedPath) {
+    assertTrue(result);
+    checkPath(delegator, expectedDelegatedPath);
+  }
+
+  /////////////////////////////////////////////////////////////////////////////
+  // Tests:
+  /////////////////////////////////////////////////////////////////////////////
+
+  @Test
+  public void testIsReadableCallOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected boolean isReadable(Path path) {
+        lastPath = path;
+        return booleanState();
+      }
+    };
+    scopedFS().setDelegator(delegator);
+
+    delegator.setState(false);
+    assertFalseWithPathCheck(fileLink.isReadable(), delegator, fileLinkTarget);
+    assertFalseWithPathCheck(dirLink.getRelative("a").isReadable(), delegator,
+        dirLinkTarget.getRelative("a"));
+
+    delegator.setState(true);
+    assertTrueWithPathCheck(fileLink.isReadable(), delegator, fileLinkTarget);
+    assertTrueWithPathCheck(dirLink.getRelative("a").isReadable(), delegator,
+        dirLinkTarget.getRelative("a"));
+  }
+
+  @Test
+  public void testIsWritableCallOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected boolean isWritable(Path path) {
+        lastPath = path;
+        return booleanState();
+      }
+    };
+    scopedFS().setDelegator(delegator);
+
+    delegator.setState(false);
+    assertFalseWithPathCheck(fileLink.isWritable(), delegator, fileLinkTarget);
+    assertFalseWithPathCheck(dirLink.getRelative("a").isWritable(), delegator,
+        dirLinkTarget.getRelative("a"));
+
+    delegator.setState(true);
+    assertTrueWithPathCheck(fileLink.isWritable(), delegator, fileLinkTarget);
+    assertTrueWithPathCheck(dirLink.getRelative("a").isWritable(), delegator,
+        dirLinkTarget.getRelative("a"));
+  }
+
+  @Test
+  public void testisExecutableCallOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected boolean isExecutable(Path path) {
+        lastPath = path;
+        return booleanState();
+      }
+    };
+    scopedFS().setDelegator(delegator);
+
+    delegator.setState(false);
+    assertFalseWithPathCheck(fileLink.isExecutable(), delegator, fileLinkTarget);
+    assertFalseWithPathCheck(dirLink.getRelative("a").isExecutable(), delegator,
+        dirLinkTarget.getRelative("a"));
+
+    delegator.setState(true);
+    assertTrueWithPathCheck(fileLink.isExecutable(), delegator, fileLinkTarget);
+    assertTrueWithPathCheck(dirLink.getRelative("a").isExecutable(), delegator,
+        dirLinkTarget.getRelative("a"));
+  }
+
+  @Test
+  public void testIsDirectoryCallOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected boolean isDirectory(Path path, boolean followSymlinks) {
+        lastPath = path;
+        return booleanState();
+      }
+      @Override protected boolean exists(Path path, boolean followSymlinks) { return true; }
+      @Override protected long getLastModifiedTime(Path path, boolean followSymlinks) { return 0; }
+    };
+    scopedFS().setDelegator(delegator);
+
+    delegator.setState(false);
+    assertFalseWithPathCheck(fileLink.isDirectory(), delegator, fileLinkTarget);
+    assertFalseWithPathCheck(dirLink.getRelative("a").isDirectory(), delegator,
+        dirLinkTarget.getRelative("a"));
+
+    delegator.setState(true);
+    assertTrueWithPathCheck(fileLink.isDirectory(), delegator, fileLinkTarget);
+    assertTrueWithPathCheck(dirLink.getRelative("a").isDirectory(), delegator,
+        dirLinkTarget.getRelative("a"));
+  }
+
+  @Test
+  public void testIsFileCallOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected boolean isFile(Path path, boolean followSymlinks) {
+        lastPath = path;
+        return booleanState();
+      }
+      @Override protected boolean exists(Path path, boolean followSymlinks) { return true; }
+      @Override protected long getLastModifiedTime(Path path, boolean followSymlinks) { return 0; }
+    };
+    scopedFS().setDelegator(delegator);
+
+    delegator.setState(false);
+    assertFalseWithPathCheck(fileLink.isFile(), delegator, fileLinkTarget);
+    assertFalseWithPathCheck(dirLink.getRelative("a").isFile(), delegator,
+        dirLinkTarget.getRelative("a"));
+
+    delegator.setState(true);
+    assertTrueWithPathCheck(fileLink.isFile(), delegator, fileLinkTarget);
+    assertTrueWithPathCheck(dirLink.getRelative("a").isFile(), delegator,
+        dirLinkTarget.getRelative("a"));
+  }
+
+  @Test
+  public void testIsSymbolicLinkCallOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected boolean isSymbolicLink(Path path) {
+        lastPath = path;
+        return booleanState();
+      }
+      @Override protected boolean exists(Path path, boolean followSymlinks) { return true; }
+      @Override protected long getLastModifiedTime(Path path, boolean followSymlinks) { return 0; }
+      @Override protected boolean isDirectory(Path path, boolean followSymlinks) { return true; }
+    };
+    scopedFS().setDelegator(delegator);
+
+    // We shouldn't follow final-segment links, so they should never invoke the delegator.
+    delegator.setState(false);
+    assertTrue(fileLink.isSymbolicLink());
+    assertTrue(delegator.lastPath() == null);
+
+    assertFalseWithPathCheck(dirLink.getRelative("a").isSymbolicLink(), delegator,
+        dirLinkTarget.getRelative("a"));
+
+    delegator.setState(true);
+    assertTrueWithPathCheck(dirLink.getRelative("a").isSymbolicLink(), delegator,
+        dirLinkTarget.getRelative("a"));
+  }
+
+  /**
+   * Returns a test delegator that reflects info passed to Path.exists() calls.
+   */
+  private TestDelegator newExistsDelegator() {
+    return new TestDelegator() {
+      @Override protected boolean exists(Path path, boolean followSymlinks) {
+        lastPath = path;
+        return booleanState();
+      }
+      @Override protected FileStatus stat(Path path, boolean followSymlinks) throws IOException {
+        if (!exists(path, followSymlinks)) {
+          throw new IOException("Expected exception on stat of non-existent file");
+        }
+        return super.stat(path, followSymlinks);
+      }
+      @Override protected long getLastModifiedTime(Path path, boolean followSymlinks) { return 0; }
+    };
+  }
+
+  @Test
+  public void testExistsCallOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = newExistsDelegator();
+    scopedFS().setDelegator(delegator);
+
+    delegator.setState(false);
+    assertFalseWithPathCheck(fileLink.exists(), delegator, fileLinkTarget);
+    assertFalseWithPathCheck(dirLink.getRelative("a").exists(), delegator,
+        dirLinkTarget.getRelative("a"));
+
+    delegator.setState(true);
+    assertTrueWithPathCheck(fileLink.exists(), delegator, fileLinkTarget);
+    assertTrueWithPathCheck(dirLink.getRelative("a").exists(), delegator,
+        dirLinkTarget.getRelative("a"));
+  }
+
+  @Test
+  public void testCreateDirectoryCallOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected boolean createDirectory(Path path) {
+        lastPath = path;
+        return booleanState();
+      }
+      @Override protected boolean isDirectory(Path path, boolean followSymlinks) { return true; }
+    };
+    scopedFS().setDelegator(delegator);
+
+    delegator.setState(false);
+    assertFalseWithPathCheck(dirLink.getRelative("a").createDirectory(), delegator,
+        dirLinkTarget.getRelative("a"));
+
+    delegator.setState(true);
+    assertTrueWithPathCheck(dirLink.getRelative("a").createDirectory(), delegator,
+        dirLinkTarget.getRelative("a"));
+  }
+
+  @Test
+  public void testDeleteCallOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected boolean delete(Path path) {
+        lastPath = path;
+        return booleanState();
+      }
+      @Override protected boolean isDirectory(Path path, boolean followSymlinks) { return true; }
+      @Override protected long getLastModifiedTime(Path path, boolean followSymlinks) { return 0; }
+    };
+    scopedFS().setDelegator(delegator);
+
+    delegator.setState(false);
+    assertTrue(fileLink.delete());
+    assertTrue(delegator.lastPath() == null);  // Deleting a link shouldn't require delegation.
+    assertFalseWithPathCheck(dirLink.getRelative("a").delete(), delegator,
+        dirLinkTarget.getRelative("a"));
+
+    delegator.setState(true);
+    assertTrueWithPathCheck(dirLink.getRelative("a").delete(), delegator,
+        dirLinkTarget.getRelative("a"));
+  }
+
+  @Test
+  public void testCallGetFileSizeOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected long getFileSize(Path path, boolean followSymlinks) {
+        lastPath = path;
+        return longState();
+      }
+      @Override protected long getLastModifiedTime(Path path, boolean followSymlinks) { return 0; }
+    };
+    scopedFS().setDelegator(delegator);
+
+    final int state1 = 10;
+    delegator.setState(state1);
+    assertEquals(state1, fileLink.getFileSize());
+    checkPath(delegator, fileLinkTarget);
+    assertEquals(state1, dirLink.getRelative("a").getFileSize());
+    checkPath(delegator, dirLinkTarget.getRelative("a"));
+
+    final int state2 = 10;
+    delegator.setState(state2);
+    assertEquals(state2, fileLink.getFileSize());
+    checkPath(delegator, fileLinkTarget);
+    assertEquals(state2, dirLink.getRelative("a").getFileSize());
+    checkPath(delegator, dirLinkTarget.getRelative("a"));
+   }
+
+  @Test
+  public void testCallGetLastModifiedTimeOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected long getLastModifiedTime(Path path, boolean followSymlinks) {
+        lastPath = path;
+        return longState();
+      }
+    };
+    scopedFS().setDelegator(delegator);
+
+    final int state1 = 10;
+    delegator.setState(state1);
+    assertEquals(state1, fileLink.getLastModifiedTime());
+    checkPath(delegator, fileLinkTarget);
+    assertEquals(state1, dirLink.getRelative("a").getLastModifiedTime());
+    checkPath(delegator, dirLinkTarget.getRelative("a"));
+
+    final int state2 = 10;
+    delegator.setState(state2);
+    assertEquals(state2, fileLink.getLastModifiedTime());
+    checkPath(delegator, fileLinkTarget);
+    assertEquals(state2, dirLink.getRelative("a").getLastModifiedTime());
+    checkPath(delegator, dirLinkTarget.getRelative("a"));
+  }
+
+  @Test
+  public void testCallSetReadableOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected void setReadable(Path path, boolean readable) {
+        lastPath = path;
+        setState(readable);
+      }
+    };
+    scopedFS().setDelegator(delegator);
+
+    delegator.setState(false);
+    fileLink.setReadable(true);
+    assertTrue(delegator.booleanState());
+    checkPath(delegator, fileLinkTarget);
+    fileLink.setReadable(false);
+    assertFalse(delegator.booleanState());
+    checkPath(delegator, fileLinkTarget);
+
+    delegator.setState(false);
+    dirLink.getRelative("a").setReadable(true);
+    assertTrue(delegator.booleanState());
+    checkPath(delegator, dirLinkTarget.getRelative("a"));
+    dirLink.getRelative("a").setReadable(false);
+    assertFalse(delegator.booleanState());
+    checkPath(delegator, dirLinkTarget.getRelative("a"));
+  }
+
+  @Test
+  public void testCallSetWritableOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected void setWritable(Path path, boolean writable) {
+        lastPath = path;
+        setState(writable);
+      }
+    };
+    scopedFS().setDelegator(delegator);
+
+    delegator.setState(false);
+    fileLink.setWritable(true);
+    assertTrue(delegator.booleanState());
+    checkPath(delegator, fileLinkTarget);
+    fileLink.setWritable(false);
+    assertFalse(delegator.booleanState());
+    checkPath(delegator, fileLinkTarget);
+
+    delegator.setState(false);
+    dirLink.getRelative("a").setWritable(true);
+    assertTrue(delegator.booleanState());
+    checkPath(delegator, dirLinkTarget.getRelative("a"));
+    dirLink.getRelative("a").setWritable(false);
+    assertFalse(delegator.booleanState());
+    checkPath(delegator, dirLinkTarget.getRelative("a"));
+  }
+
+  @Test
+  public void testCallSetExecutableOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected void setReadable(Path path, boolean readable) {
+        lastPath = path;
+        setState(readable);
+      }
+    };
+    scopedFS().setDelegator(delegator);
+
+    delegator.setState(false);
+    fileLink.setReadable(true);
+    assertTrue(delegator.booleanState());
+    checkPath(delegator, fileLinkTarget);
+    fileLink.setReadable(false);
+    assertFalse(delegator.booleanState());
+    checkPath(delegator, fileLinkTarget);
+
+    delegator.setState(false);
+    dirLink.getRelative("a").setReadable(true);
+    assertTrue(delegator.booleanState());
+    checkPath(delegator, dirLinkTarget.getRelative("a"));
+    dirLink.getRelative("a").setReadable(false);
+    assertFalse(delegator.booleanState());
+    checkPath(delegator, dirLinkTarget.getRelative("a"));
+  }
+
+  @Test
+  public void testCallSetLastModifiedTimeOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected void setLastModifiedTime(Path path, long newTime) {
+        lastPath = path;
+        setState(newTime);
+      }
+    };
+    scopedFS().setDelegator(delegator);
+
+    delegator.setState(0);
+    fileLink.setLastModifiedTime(10);
+    assertEquals(10, delegator.longState());
+    checkPath(delegator, fileLinkTarget);
+    fileLink.setLastModifiedTime(15);
+    assertEquals(15, delegator.longState());
+    checkPath(delegator, fileLinkTarget);
+
+    dirLink.getRelative("a").setLastModifiedTime(20);
+    assertEquals(20, delegator.longState());
+    checkPath(delegator, dirLinkTarget.getRelative("a"));
+    dirLink.getRelative("a").setLastModifiedTime(25);
+    assertEquals(25, delegator.longState());
+    checkPath(delegator, dirLinkTarget.getRelative("a"));
+  }
+
+  @Test
+  public void testCallRenameToOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected void renameTo(Path sourcePath, Path targetPath) {
+        lastPath = sourcePath;
+        setState(targetPath);
+      }
+      @Override protected boolean isDirectory(Path path, boolean followSymlinks) { return true; }
+    };
+    scopedFS().setDelegator(delegator);
+
+    // Renaming a link should work fine.
+    delegator.setState(null);
+    fileLink.renameTo(testFS.getPath(SCOPE_ROOT).getRelative("newname"));
+    assertEquals(null, delegator.lastPath());  // Renaming a link shouldn't require delegation.
+    assertEquals(null, delegator.objectState());
+
+    // Renaming an out-of-scope path to an in-scope path should fail due to filesystem mismatch
+    // errors.
+    Path newPath = testFS.getPath(SCOPE_ROOT.getRelative("blah"));
+    try {
+      dirLink.getRelative("a").renameTo(newPath);
+      fail("This is an attempt at a cross-filesystem renaming, which should fail");
+    } catch (IOException e) {
+      // Expected.
+    }
+
+    // Renaming an out-of-scope path to another out-of-scope path can be valid.
+    newPath = dirLink.getRelative("b");
+    dirLink.getRelative("a").renameTo(newPath);
+    assertEquals(dirLinkTarget.getRelative("a"), delegator.lastPath());
+    assertEquals(dirLinkTarget.getRelative("b"), ((Path) delegator.objectState()).asFragment());
+  }
+
+  @Test
+  public void testCallCreateSymbolicLinkOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected void createSymbolicLink(Path linkPath, PathFragment targetFragment) {
+        lastPath = linkPath;
+        setState(targetFragment);
+      }
+      @Override protected boolean isDirectory(Path path, boolean followSymlinks) { return true; }
+    };
+    scopedFS().setDelegator(delegator);
+
+    PathFragment newLinkTarget = new PathFragment("/something/else");
+    dirLink.getRelative("a").createSymbolicLink(newLinkTarget);
+    assertEquals(dirLinkTarget.getRelative("a"), delegator.lastPath());
+    assertSame(newLinkTarget, delegator.objectState());
+  }
+
+  @Test
+  public void testCallReadSymbolicLinkOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected PathFragment readSymbolicLink(Path path) {
+        lastPath = path;
+        return (PathFragment) objectState;
+      }
+      @Override protected boolean isDirectory(Path path, boolean followSymlinks) { return true; }
+    };
+    scopedFS().setDelegator(delegator);
+
+    // Since we're not following the link, this shouldn't invoke delegation.
+    delegator.setState(new PathFragment("whatever"));
+    PathFragment p = fileLink.readSymbolicLink();
+    assertEquals(null, delegator.lastPath());
+    assertNotSame(delegator.objectState(), p);
+
+    // This should.
+    p = dirLink.getRelative("a").readSymbolicLink();
+    assertEquals(dirLinkTarget.getRelative("a"), delegator.lastPath());
+    assertSame(delegator.objectState(), p);
+  }
+
+  @Test
+  public void testCallGetInputStreamOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected InputStream getInputStream(Path path) {
+        lastPath = path;
+        return (InputStream) objectState;
+      }
+    };
+    scopedFS().setDelegator(delegator);
+
+    delegator.setState(new ByteArrayInputStream("blah".getBytes()));
+    InputStream is = fileLink.getInputStream();
+    assertEquals(fileLinkTarget, delegator.lastPath());
+    assertSame(delegator.objectState(), is);
+
+    delegator.setState(new ByteArrayInputStream("blah2".getBytes()));
+    is = dirLink.getInputStream();
+    assertEquals(dirLinkTarget, delegator.lastPath());
+    assertSame(delegator.objectState(), is);
+  }
+
+  @Test
+  public void testCallGetOutputStreamOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected OutputStream getOutputStream(Path path, boolean append)  {
+        lastPath = path;
+        return (OutputStream) objectState;
+      }
+      @Override protected boolean isDirectory(Path path, boolean followSymlinks) { return true; }
+    };
+    scopedFS().setDelegator(delegator);
+
+    delegator.setState(new ByteArrayOutputStream());
+    OutputStream os = fileLink.getOutputStream();
+    assertEquals(fileLinkTarget, delegator.lastPath());
+    assertSame(delegator.objectState(), os);
+
+    delegator.setState(new ByteArrayOutputStream());
+    os = dirLink.getOutputStream();
+    assertEquals(dirLinkTarget, delegator.lastPath());
+    assertSame(delegator.objectState(), os);
+  }
+
+  @Test
+  public void testCallGetDirectoryEntriesOnEscapingSymlink() throws Exception {
+    TestDelegator delegator = new TestDelegator() {
+      @Override protected Collection<Path> getDirectoryEntries(Path path) {
+        lastPath = path;
+        return ImmutableList.of((Path) objectState);
+      }
+      @Override protected boolean isDirectory(Path path, boolean followSymlinks) { return true; }
+    };
+    scopedFS().setDelegator(delegator);
+
+    delegator.setState(testFS.getPath("/anything"));
+    Collection<Path> entries = dirLink.getDirectoryEntries();
+    assertEquals(dirLinkTarget, delegator.lastPath());
+    assertEquals(1, entries.size());
+    assertSame(delegator.objectState(), entries.iterator().next());
+  }
+
+  /**
+   * Asserts that "link" is an in-scope link that doesn't result in an out-of-FS
+   * delegation. If link is relative, its path is relative to SCOPE_ROOT.
+   *
+   * Note that we don't actually check that the canonicalized target path matches
+   * the link's target value. Such testing should be covered by
+   * SymlinkAwareFileSystemTest.
+   */
+  private void assertInScopeLink(String link, String target, TestDelegator d) throws IOException {
+    Path l = testFS.getPath(SCOPE_ROOT.getRelative(link));
+    testFS.createSymbolicLink(l, new PathFragment(target));
+    l.exists();
+    assertNull(d.lastPath());
+  }
+
+  /**
+   * Asserts that "link" is an out-of-scope link and that the re-delegated path
+   * matches expectedPath. If link is relative, its path is relative to SCOPE_ROOT.
+   */
+  private void assertOutOfScopeLink(String link, String target, String expectedPath,
+      TestDelegator d) throws IOException {
+    Path l = testFS.getPath(SCOPE_ROOT.getRelative(link));
+    testFS.createSymbolicLink(l, new PathFragment(target));
+    l.exists();
+    assertEquals(expectedPath, d.lastPath().getPathString());
+  }
+
+  /**
+   * Returns the scope root with the final n segments chopped off (or a 0-segment path
+   * if n > SCOPE_ROOT.segmentCount()).
+   */
+  private String chopScopeRoot(int n) {
+    return SCOPE_ROOT
+        .subFragment(0, n > SCOPE_ROOT.segmentCount() ? 0 : SCOPE_ROOT.segmentCount() - n)
+        .getPathString();
+  }
+
+  /**
+   * Tests that absolute symlinks with ".." and "." segments are delegated to
+   * the expected paths.
+   */
+  @Test
+  public void testAbsoluteSymlinksWithParentReferences() throws Exception {
+    TestDelegator d = newExistsDelegator();
+    scopedFS().setDelegator(d);
+    testFS.createDirectory(testFS.getPath(SCOPE_ROOT.getRelative("dir")));
+    String scopeRoot = SCOPE_ROOT.getPathString();
+    String scopeBase = SCOPE_ROOT.getBaseName();
+
+    // Symlinks that should never escape our scope.
+    assertInScopeLink("ilink1", scopeRoot, d);
+    assertInScopeLink("ilink2", scopeRoot + "/target", d);
+    assertInScopeLink("ilink3", scopeRoot + "/dir/../target", d);
+    assertInScopeLink("ilink4", scopeRoot + "/dir/../dir/dir2/../target", d);
+    assertInScopeLink("ilink5", scopeRoot + "/./dir/.././target", d);
+    assertInScopeLink("ilink6", scopeRoot + "/../" + scopeBase + "/target", d);
+    assertInScopeLink("ilink7", "/some/path/../.." + scopeRoot + "/target", d);
+
+    // Symlinks that should escape our scope.
+    assertOutOfScopeLink("olink1", scopeRoot + "/../target", chopScopeRoot(1) + "/target", d);
+    assertOutOfScopeLink("olink2", "/some/other/path", "/some/other/path", d);
+    assertOutOfScopeLink("olink3", scopeRoot + "/../target", chopScopeRoot(1) + "/target", d);
+    assertOutOfScopeLink("olink4", chopScopeRoot(1) + "/target", chopScopeRoot(1) + "/target", d);
+    assertOutOfScopeLink("olink5", scopeRoot + "/../../../../target", "/target", d);
+
+    // In-scope symlink that's not the final segment in a query.
+    Path iDirLink = testFS.getPath(SCOPE_ROOT.getRelative("ilinkdir"));
+    testFS.createSymbolicLink(iDirLink, SCOPE_ROOT.getRelative("dir"));
+    iDirLink.getRelative("file").exists();
+    assertNull(d.lastPath());
+
+    // Out-of-scope symlink that's not the final segment in a query.
+    Path oDirLink = testFS.getPath(SCOPE_ROOT.getRelative("olinkdir"));
+    testFS.createSymbolicLink(oDirLink, new PathFragment("/some/other/dir"));
+    oDirLink.getRelative("file").exists();
+    assertEquals("/some/other/dir/file", d.lastPath().getPathString());
+  }
+
+  /**
+   * Tests that relative symlinks with ".." and "." segments are delegated to
+   * the expected paths.
+   */
+  @Test
+  public void testRelativeSymlinksWithParentReferences() throws Exception {
+    TestDelegator d = newExistsDelegator();
+    scopedFS().setDelegator(d);
+    testFS.createDirectory(testFS.getPath(SCOPE_ROOT.getRelative("dir")));
+    testFS.createDirectory(testFS.getPath(SCOPE_ROOT.getRelative("dir/dir2")));
+    testFS.createDirectory(testFS.getPath(SCOPE_ROOT.getRelative("dir/dir2/dir3")));
+    String scopeRoot = SCOPE_ROOT.getPathString();
+    String scopeBase = SCOPE_ROOT.getBaseName();
+
+    // Symlinks that should never escape our scope.
+    assertInScopeLink("ilink1", "target", d);
+    assertInScopeLink("ilink2", "dir/../otherdir/target", d);
+    assertInScopeLink("dir/ilink3", "../target", d);
+    assertInScopeLink("dir/dir2/ilink4", "../../target", d);
+    assertInScopeLink("dir/dir2/ilink5", ".././../dir/./target", d);
+    assertInScopeLink("dir/dir2/ilink6", "../dir2/../../dir/dir2/dir3/../../../target", d);
+
+    // Symlinks that should escape our scope.
+    assertOutOfScopeLink("olink1", "../target", chopScopeRoot(1) + "/target", d);
+    assertOutOfScopeLink("dir/olink2", "../../target", chopScopeRoot(1) + "/target", d);
+    assertOutOfScopeLink("olink3", "../" + scopeBase + "/target", scopeRoot + "/target", d);
+    assertOutOfScopeLink("dir/dir2/olink5", "../../../target", chopScopeRoot(1) + "/target", d);
+    assertOutOfScopeLink("dir/dir2/olink6", "../dir2/../../dir/dir2/../../../target",
+        chopScopeRoot(1) + "/target", d);
+    assertOutOfScopeLink("dir/olink7", "../../../target", chopScopeRoot(2) + "target", d);
+    assertOutOfScopeLink("olink8", "../../../../../target", "/target", d);
+
+    // In-scope symlink that's not the final segment in a query.
+    Path iDirLink = testFS.getPath(SCOPE_ROOT.getRelative("dir/dir2/ilinkdir"));
+    testFS.createSymbolicLink(iDirLink, new PathFragment("../../dir"));
+    iDirLink.getRelative("file").exists();
+    assertNull(d.lastPath());
+
+    // Out-of-scope symlink that's not the final segment in a query.
+    Path oDirLink = testFS.getPath(SCOPE_ROOT.getRelative("dir/dir2/olinkdir"));
+    testFS.createSymbolicLink(oDirLink, new PathFragment("../../../other/dir"));
+    oDirLink.getRelative("file").exists();
+    assertEquals(chopScopeRoot(1) + "/other/dir/file", d.lastPath().getPathString());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/SymlinkAwareFileSystemTest.java b/src/test/java/com/google/devtools/build/lib/vfs/SymlinkAwareFileSystemTest.java
new file mode 100644
index 0000000..a728c88
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/SymlinkAwareFileSystemTest.java
@@ -0,0 +1,717 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.devtools.build.lib.testutil.MoreAsserts;
+import com.google.devtools.build.lib.vfs.FileSystem.NotASymlinkException;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.Collection;
+
+/**
+ * This class handles the generic tests that any filesystem must pass.
+ *
+ * <p>Each filesystem-test should inherit from this class, thereby obtaining
+ * all the tests.
+ */
+public abstract class SymlinkAwareFileSystemTest extends FileSystemTest {
+
+  protected Path xLinkToFile;
+  protected Path xLinkToLinkToFile;
+  protected Path xLinkToDirectory;
+  protected Path xDanglingLink;
+
+  @Override
+  @Before
+  public void setUp() throws Exception {
+    super.setUp();
+
+    // % ls -lR
+    // -rw-rw-r-- xFile
+    // drwxrwxr-x xNonEmptyDirectory
+    // -rw-rw-r-- xNonEmptyDirectory/foo
+    // drwxrwxr-x xEmptyDirectory
+    // lrwxrwxr-x xLinkToFile -> xFile
+    // lrwxrwxr-x xLinkToDirectory -> xEmptyDirectory
+    // lrwxrwxr-x xLinkToLinkToFile -> xLinkToFile
+    // lrwxrwxr-x xDanglingLink -> xNothing
+
+    xLinkToFile = absolutize("xLinkToFile");
+    xLinkToLinkToFile = absolutize("xLinkToLinkToFile");
+    xLinkToDirectory = absolutize("xLinkToDirectory");
+    xDanglingLink = absolutize("xDanglingLink");
+
+    createSymbolicLink(xLinkToFile, xFile);
+    createSymbolicLink(xLinkToLinkToFile, xLinkToFile);
+    createSymbolicLink(xLinkToDirectory, xEmptyDirectory);
+    createSymbolicLink(xDanglingLink, xNothing);
+  }
+
+  @Test
+  public void testCreateLinkToFile() throws IOException {
+    Path newPath = xEmptyDirectory.getChild("new-file");
+    FileSystemUtils.createEmptyFile(newPath);
+
+    Path linkPath = xEmptyDirectory.getChild("some-link");
+
+    createSymbolicLink(linkPath, newPath);
+
+    assertTrue(linkPath.isSymbolicLink());
+
+    assertTrue(linkPath.isFile());
+    assertFalse(linkPath.isFile(Symlinks.NOFOLLOW));
+    assertTrue(linkPath.isFile(Symlinks.FOLLOW));
+
+    assertFalse(linkPath.isDirectory());
+    assertFalse(linkPath.isDirectory(Symlinks.NOFOLLOW));
+    assertFalse(linkPath.isDirectory(Symlinks.FOLLOW));
+
+    if (supportsSymlinks) {
+      assertEquals(newPath.toString().length(), linkPath.getFileSize(Symlinks.NOFOLLOW));
+      assertEquals(newPath.getFileSize(Symlinks.NOFOLLOW), linkPath.getFileSize());
+    }
+    assertEquals(2,
+                 linkPath.getParentDirectory().getDirectoryEntries().size());
+    assertThat(linkPath.getParentDirectory().getDirectoryEntries()).containsExactly(newPath,
+        linkPath);
+  }
+
+  @Test
+  public void testCreateLinkToDirectory() throws IOException {
+    Path newPath = xEmptyDirectory.getChild("new-file");
+    newPath.createDirectory();
+
+    Path linkPath = xEmptyDirectory.getChild("some-link");
+
+    createSymbolicLink(linkPath, newPath);
+
+    assertTrue(linkPath.isSymbolicLink());
+    assertFalse(linkPath.isFile());
+    assertTrue(linkPath.isDirectory());
+    assertEquals(2,
+                 linkPath.getParentDirectory().getDirectoryEntries().size());
+    assertThat(linkPath.getParentDirectory().
+      getDirectoryEntries()).containsExactly(newPath, linkPath);
+  }
+
+  @Test
+  public void testFileCanonicalPath() throws IOException {
+    Path newPath = absolutize("new-file");
+    FileSystemUtils.createEmptyFile(newPath);
+    newPath = newPath.resolveSymbolicLinks();
+
+    Path link1 = absolutize("some-link");
+    Path link2 = absolutize("some-link2");
+
+    createSymbolicLink(link1, newPath);
+    createSymbolicLink(link2, link1);
+
+    assertCanonicalPathsMatch(newPath, link1, link2);
+  }
+
+  @Test
+  public void testDirectoryCanonicalPath() throws IOException {
+    Path newPath = absolutize("new-folder");
+    newPath.createDirectory();
+    newPath = newPath.resolveSymbolicLinks();
+
+    Path newFile = newPath.getChild("file");
+    FileSystemUtils.createEmptyFile(newFile);
+
+    Path link1 = absolutize("some-link");
+    Path link2 = absolutize("some-link2");
+
+    createSymbolicLink(link1, newPath);
+    createSymbolicLink(link2, link1);
+
+    Path linkFile1 = link1.getChild("file");
+    Path linkFile2 = link2.getChild("file");
+
+    assertCanonicalPathsMatch(newFile, linkFile1, linkFile2);
+  }
+
+  private void assertCanonicalPathsMatch(Path newPath, Path link1, Path link2)
+      throws IOException {
+    assertEquals(newPath, link1.resolveSymbolicLinks());
+    assertEquals(newPath, link2.resolveSymbolicLinks());
+  }
+
+  //
+  //  createDirectory
+  //
+
+  @Test
+  public void testCreateDirectoryWhereDanglingSymlinkAlreadyExists() {
+    try {
+      xDanglingLink.createDirectory();
+      fail();
+    } catch (IOException e) {
+      assertEquals(xDanglingLink + " (File exists)", e.getMessage());
+    }
+    assertTrue(xDanglingLink.isSymbolicLink()); // still a symbolic link
+    assertFalse(xDanglingLink.isDirectory(Symlinks.FOLLOW)); // link still dangles
+  }
+
+  @Test
+  public void testCreateDirectoryWhereSymlinkAlreadyExists() {
+    try {
+      xLinkToDirectory.createDirectory();
+      fail();
+    } catch (IOException e) {
+      assertEquals(xLinkToDirectory + " (File exists)", e.getMessage());
+    }
+    assertTrue(xLinkToDirectory.isSymbolicLink()); // still a symbolic link
+    assertTrue(xLinkToDirectory.isDirectory(Symlinks.FOLLOW)); // link still points to dir
+  }
+
+  //  createSymbolicLink(PathFragment)
+
+  @Test
+  public void testCreateSymbolicLinkFromFragment() throws IOException {
+    String[] linkTargets = {
+      "foo",
+      "foo/bar",
+      ".",
+      "..",
+      "../foo",
+      "../../foo",
+      "../../../../../../../../../../../../../../../../../../../../../foo",
+      "/foo",
+      "/foo/bar",
+      "/..",
+      "/foo/../bar",
+    };
+    Path linkPath = absolutize("link");
+    for (String linkTarget : linkTargets) {
+      PathFragment relative = new PathFragment(linkTarget);
+      linkPath.delete();
+      createSymbolicLink(linkPath, relative);
+      if (supportsSymlinks) {
+        assertEquals(linkTarget.length(), linkPath.getFileSize(Symlinks.NOFOLLOW));
+        assertEquals(relative, linkPath.readSymbolicLink());
+      }
+    }
+  }
+
+  @Test
+  public void testLinkToRootResolvesCorrectly() throws IOException {
+    Path rootPath = testFS.getPath("/");
+    Path linkPath = absolutize("link");
+    createSymbolicLink(linkPath, rootPath);
+
+    // resolveSymbolicLinks requires an existing path:
+    try {
+      linkPath.getRelative("test").resolveSymbolicLinks();
+      fail();
+    } catch (FileNotFoundException e) { /* ok */ }
+
+    // The path may not be a symlink, neither on Darwin nor on Linux.
+    Path rootChild = testFS.getPath("/sbin");
+    if (!rootChild.isDirectory()) {
+      rootChild.createDirectory();
+    }
+    assertEquals(rootChild, linkPath.getRelative("sbin").resolveSymbolicLinks());
+  }
+
+  @Test
+  public void testLinkToFragmentContainingLinkResolvesCorrectly() throws IOException {
+    Path link1 = absolutize("link1");
+    PathFragment link1target = new PathFragment("link2/foo");
+    Path link2 = absolutize("link2");
+    Path link2target = xNonEmptyDirectory;
+
+    createSymbolicLink(link1, link1target); // ln -s link2/foo link1
+    createSymbolicLink(link2, link2target); // ln -s xNonEmptyDirectory link2
+    // link1 --> xNonEmptyDirectory/foo
+    assertEquals(link1.resolveSymbolicLinks(), link2target.getRelative("foo"));
+  }
+
+  //
+  //  readSymbolicLink / resolveSymbolicLinks
+  //
+
+  @Test
+  public void testRecursiveSymbolicLink() throws IOException {
+    Path link = absolutize("recursive-link");
+    createSymbolicLink(link, link);
+
+    if (supportsSymlinks) {
+      try {
+        link.resolveSymbolicLinks();
+        fail();
+      } catch (IOException e) {
+        assertEquals(link + " (Too many levels of symbolic links)",
+                     e.getMessage());
+      }
+    }
+  }
+
+  @Test
+  public void testMutuallyRecursiveSymbolicLinks() throws IOException {
+    Path link1 = absolutize("link1");
+    Path link2 = absolutize("link2");
+    createSymbolicLink(link2, link1);
+    createSymbolicLink(link1, link2);
+
+    if (supportsSymlinks) {
+      try {
+        link1.resolveSymbolicLinks();
+        fail();
+      } catch (IOException e) {
+        assertEquals(link1 + " (Too many levels of symbolic links)", e.getMessage());
+      }
+    }
+  }
+
+  @Test
+  public void testResolveSymbolicLinksENOENT() {
+    if (supportsSymlinks) {
+      try {
+        xDanglingLink.resolveSymbolicLinks();
+        fail();
+      } catch (IOException e) {
+        assertEquals(xNothing + " (No such file or directory)", e.getMessage());
+      }
+    }
+  }
+
+  @Test
+  public void testResolveSymbolicLinksENOTDIR() throws IOException {
+    if (supportsSymlinks) {
+      Path badLinkTarget = xFile.getChild("bad"); // parent is not a directory!
+      Path badLink = absolutize("badLink");
+      createSymbolicLink(badLink, badLinkTarget);
+      try {
+        badLink.resolveSymbolicLinks();
+        fail();
+      } catch (IOException e) {
+        // ok.  Ideally we would assert "(Not a directory)" in the error
+        // message, but that would require yet another stat in the
+        // implementation.
+      }
+    }
+  }
+
+  @Test
+  public void testResolveSymbolicLinksWithUplevelRefs() throws IOException {
+    if (supportsSymlinks) {
+      // Create a series of links that refer to xFile as ./xFile,
+      // ./../foo/xFile, ./../../bar/foo/xFile, etc.  They should all resolve
+      // to xFile.
+      Path ancestor = xFile;
+      String prefix = "./";
+      while ((ancestor = ancestor.getParentDirectory()) != null) {
+        xLinkToFile.delete();
+        createSymbolicLink(xLinkToFile, new PathFragment(prefix + xFile.relativeTo(ancestor)));
+        assertEquals(xFile, xLinkToFile.resolveSymbolicLinks());
+
+        prefix += "../";
+      }
+    }
+  }
+
+  @Test
+  public void testReadSymbolicLink() throws IOException {
+    if (supportsSymlinks) {
+      assertEquals(xNothing.toString(),
+                   xDanglingLink.readSymbolicLink().toString());
+    }
+
+    assertEquals(xFile.toString(),
+                 xLinkToFile.readSymbolicLink().toString());
+
+    assertEquals(xEmptyDirectory.toString(),
+                 xLinkToDirectory.readSymbolicLink().toString());
+
+    try {
+      xFile.readSymbolicLink(); // not a link
+      fail();
+    } catch (NotASymlinkException e) {
+      assertEquals(xFile.toString(), e.getMessage());
+    }
+
+    try {
+      xNothing.readSymbolicLink(); // nothing there
+      fail();
+    } catch (IOException e) {
+      assertEquals(xNothing + " (No such file or directory)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testCannotCreateSymbolicLinkWithReadOnlyParent()
+      throws IOException {
+    xEmptyDirectory.setWritable(false);
+    Path xChildOfReadonlyDir = xEmptyDirectory.getChild("x");
+    if (supportsSymlinks) {
+      try {
+        xChildOfReadonlyDir.createSymbolicLink(xNothing);
+        fail();
+      } catch (IOException e) {
+        assertEquals(xChildOfReadonlyDir + " (Permission denied)", e.getMessage());
+      }
+    }
+  }
+
+  //
+  // createSymbolicLink
+  //
+
+  @Test
+  public void testCanCreateDanglingLink() throws IOException {
+    Path newPath = absolutize("non-existing-dir/new-file");
+    Path someLink = absolutize("dangling-link");
+    createSymbolicLink(someLink, newPath);
+    assertTrue(someLink.isSymbolicLink());
+    assertTrue(someLink.exists(Symlinks.NOFOLLOW)); // the link itself exists
+    assertFalse(someLink.exists()); // ...but the referent doesn't
+    if (supportsSymlinks) {
+      try {
+        someLink.resolveSymbolicLinks();
+      } catch (FileNotFoundException e) {
+        assertEquals(newPath.getParentDirectory()
+                     + " (No such file or directory)", e.getMessage());
+      }
+    }
+  }
+
+  @Test
+  public void testCannotCreateSymbolicLinkWithoutParent() throws IOException {
+    Path xChildOfMissingDir = xNothing.getChild("x");
+    if (supportsSymlinks) {
+      try {
+        xChildOfMissingDir.createSymbolicLink(xFile);
+        fail();
+      } catch (FileNotFoundException e) {
+        MoreAsserts.assertEndsWith(" (No such file or directory)", e.getMessage());
+      }
+    }
+  }
+
+  @Test
+  public void testCreateSymbolicLinkWhereNothingExists() throws IOException {
+    createSymbolicLink(xNothing, xFile);
+    assertTrue(xNothing.isSymbolicLink());
+  }
+
+  @Test
+  public void testCreateSymbolicLinkWhereDirectoryAlreadyExists() {
+    try {
+      createSymbolicLink(xEmptyDirectory, xFile);
+      fail();
+    } catch (IOException e) { // => couldn't be created
+      assertEquals(xEmptyDirectory + " (File exists)", e.getMessage());
+    }
+    assertTrue(xEmptyDirectory.isDirectory(Symlinks.NOFOLLOW));
+  }
+
+  @Test
+  public void testCreateSymbolicLinkWhereFileAlreadyExists() {
+    try {
+      createSymbolicLink(xFile, xEmptyDirectory);
+      fail();
+    } catch (IOException e) { // => couldn't be created
+      assertEquals(xFile + " (File exists)", e.getMessage());
+    }
+    assertTrue(xFile.isFile(Symlinks.NOFOLLOW));
+  }
+
+  @Test
+  public void testCreateSymbolicLinkWhereDanglingSymlinkAlreadyExists() {
+    try {
+      createSymbolicLink(xDanglingLink, xFile);
+      fail();
+    } catch (IOException e) {
+      assertEquals(xDanglingLink + " (File exists)", e.getMessage());
+    }
+    assertTrue(xDanglingLink.isSymbolicLink()); // still a symbolic link
+    assertFalse(xDanglingLink.isDirectory()); // link still dangles
+  }
+
+  @Test
+  public void testCreateSymbolicLinkWhereSymlinkAlreadyExists() {
+    try {
+      createSymbolicLink(xLinkToDirectory, xNothing);
+      fail();
+    } catch (IOException e) {
+      assertEquals(xLinkToDirectory + " (File exists)", e.getMessage());
+    }
+    assertTrue(xLinkToDirectory.isSymbolicLink()); // still a symbolic link
+    assertTrue(xLinkToDirectory.isDirectory()); // link still points to dir
+  }
+
+  @Test
+  public void testDeleteLink() throws IOException {
+    Path newPath = xEmptyDirectory.getChild("new-file");
+    Path someLink = xEmptyDirectory.getChild("a-link");
+    FileSystemUtils.createEmptyFile(newPath);
+    createSymbolicLink(someLink, newPath);
+
+    assertEquals(xEmptyDirectory.getDirectoryEntries().size(), 2);
+
+    assertTrue(someLink.delete());
+    assertEquals(xEmptyDirectory.getDirectoryEntries().size(), 1);
+
+    assertThat(xEmptyDirectory.getDirectoryEntries()).containsExactly(newPath);
+  }
+
+  // Testing the links
+  @Test
+  public void testLinkFollowedToDirectory() throws IOException {
+    Path theDirectory = absolutize("foo/");
+    assertTrue(theDirectory.createDirectory());
+    Path newPath1 = absolutize("foo/new-file-1");
+    Path newPath2 = absolutize("foo/new-file-2");
+    Path newPath3 = absolutize("foo/new-file-3");
+
+    FileSystemUtils.createEmptyFile(newPath1);
+    FileSystemUtils.createEmptyFile(newPath2);
+    FileSystemUtils.createEmptyFile(newPath3);
+
+    Path linkPath = absolutize("link");
+    createSymbolicLink(linkPath, theDirectory);
+
+    Path resultPath1 = absolutize("link/new-file-1");
+    Path resultPath2 = absolutize("link/new-file-2");
+    Path resultPath3 = absolutize("link/new-file-3");
+    assertThat(linkPath.getDirectoryEntries()).containsExactly(resultPath1, resultPath2,
+        resultPath3);
+  }
+
+  @Test
+  public void testDanglingLinkIsNoFile() throws IOException {
+    Path newPath1 = absolutize("new-file-1");
+    Path newPath2 = absolutize("new-file-2");
+    FileSystemUtils.createEmptyFile(newPath1);
+    assertTrue(newPath2.createDirectory());
+
+    Path linkPath1 = absolutize("link1");
+    Path linkPath2 = absolutize("link2");
+    createSymbolicLink(linkPath1, newPath1);
+    createSymbolicLink(linkPath2, newPath2);
+
+    newPath1.delete();
+    newPath2.delete();
+
+    assertFalse(linkPath1.isFile());
+    assertFalse(linkPath2.isDirectory());
+  }
+
+  @Test
+  public void testWriteOnLinkChangesFile() throws IOException {
+    Path testFile = absolutize("test-file");
+    FileSystemUtils.createEmptyFile(testFile);
+    String testData = "abc19";
+
+    Path testLink = absolutize("a-link");
+    createSymbolicLink(testLink, testFile);
+
+    FileSystemUtils.writeContentAsLatin1(testLink, testData);
+    String resultData =
+      new String(FileSystemUtils.readContentAsLatin1(testFile));
+
+    assertEquals(testData,resultData);
+  }
+
+  //
+  // Symlink tests:
+  //
+
+  @Test
+  public void testExistsWithSymlinks() throws IOException {
+    Path a = absolutize("a");
+    Path b = absolutize("b");
+    FileSystemUtils.createEmptyFile(b);
+    createSymbolicLink(a, b);  // ln -sf "b" "a"
+    assertTrue(a.exists()); // = exists(FOLLOW)
+    assertTrue(b.exists()); // = exists(FOLLOW)
+    assertTrue(a.exists(Symlinks.FOLLOW));
+    assertTrue(b.exists(Symlinks.FOLLOW));
+    assertTrue(a.exists(Symlinks.NOFOLLOW));
+    assertTrue(b.exists(Symlinks.NOFOLLOW));
+    b.delete(); // "a" is now a dangling link
+    assertFalse(a.exists()); // = exists(FOLLOW)
+    assertFalse(b.exists()); // = exists(FOLLOW)
+    assertFalse(a.exists(Symlinks.FOLLOW));
+    assertFalse(b.exists(Symlinks.FOLLOW));
+
+    assertTrue(a.exists(Symlinks.NOFOLLOW)); // symlink still exists
+    assertFalse(b.exists(Symlinks.NOFOLLOW));
+  }
+
+  @Test
+  public void testIsDirectoryWithSymlinks() throws IOException {
+    Path a = absolutize("a");
+    Path b = absolutize("b");
+    b.createDirectory();
+    createSymbolicLink(a, b);  // ln -sf "b" "a"
+    assertTrue(a.isDirectory()); // = isDirectory(FOLLOW)
+    assertTrue(b.isDirectory()); // = isDirectory(FOLLOW)
+    assertTrue(a.isDirectory(Symlinks.FOLLOW));
+    assertTrue(b.isDirectory(Symlinks.FOLLOW));
+    assertFalse(a.isDirectory(Symlinks.NOFOLLOW)); // it's a link!
+    assertTrue(b.isDirectory(Symlinks.NOFOLLOW));
+    b.delete(); // "a" is now a dangling link
+    assertFalse(a.isDirectory()); // = isDirectory(FOLLOW)
+    assertFalse(b.isDirectory()); // = isDirectory(FOLLOW)
+    assertFalse(a.isDirectory(Symlinks.FOLLOW));
+    assertFalse(b.isDirectory(Symlinks.FOLLOW));
+    assertFalse(a.isDirectory(Symlinks.NOFOLLOW));
+    assertFalse(b.isDirectory(Symlinks.NOFOLLOW));
+  }
+
+  @Test
+  public void testIsFileWithSymlinks() throws IOException {
+    Path a = absolutize("a");
+    Path b = absolutize("b");
+    FileSystemUtils.createEmptyFile(b);
+    createSymbolicLink(a, b);  // ln -sf "b" "a"
+    assertTrue(a.isFile()); // = isFile(FOLLOW)
+    assertTrue(b.isFile()); // = isFile(FOLLOW)
+    assertTrue(a.isFile(Symlinks.FOLLOW));
+    assertTrue(b.isFile(Symlinks.FOLLOW));
+    assertFalse(a.isFile(Symlinks.NOFOLLOW)); // it's a link!
+    assertTrue(b.isFile(Symlinks.NOFOLLOW));
+    b.delete(); // "a" is now a dangling link
+    assertFalse(a.isFile()); // = isFile()
+    assertFalse(b.isFile()); // = isFile()
+    assertFalse(a.isFile());
+    assertFalse(b.isFile());
+    assertFalse(a.isFile(Symlinks.NOFOLLOW));
+    assertFalse(b.isFile(Symlinks.NOFOLLOW));
+  }
+
+  @Test
+  public void testGetDirectoryEntriesOnLinkToDirectory() throws Exception {
+    Path fooAlias = xNothing.getChild("foo");
+    createSymbolicLink(xNothing, xNonEmptyDirectory);
+    Collection<Path> dirents = xNothing.getDirectoryEntries();
+    assertThat(dirents).containsExactly(fooAlias);
+  }
+
+  @Test
+  public void testFilesOfLinkedDirectories() throws Exception {
+    Path child = xEmptyDirectory.getChild("child");
+    Path aliasToChild = xLinkToDirectory.getChild("child");
+
+    assertFalse(aliasToChild.exists());
+    FileSystemUtils.createEmptyFile(child);
+    assertTrue(aliasToChild.exists());
+    assertTrue(aliasToChild.isFile());
+    assertFalse(aliasToChild.isDirectory());
+
+    validateLinkedReferenceObeysReadOnly(child, aliasToChild);
+    validateLinkedReferenceObeysExecutable(child, aliasToChild);
+  }
+
+  @Test
+  public void testDirectoriesOfLinkedDirectories() throws Exception {
+    Path childDir = xEmptyDirectory.getChild("childDir");
+    Path linkToChildDir = xLinkToDirectory.getChild("childDir");
+
+    assertFalse(linkToChildDir.exists());
+    childDir.createDirectory();
+    assertTrue(linkToChildDir.exists());
+    assertTrue(linkToChildDir.isDirectory());
+    assertFalse(linkToChildDir.isFile());
+
+    validateLinkedReferenceObeysReadOnly(childDir, linkToChildDir);
+    validateLinkedReferenceObeysExecutable(childDir, linkToChildDir);
+  }
+
+  @Test
+  public void testDirectoriesOfLinkedDirectoriesOfLinkedDirectories() throws Exception {
+    Path childDir = xEmptyDirectory.getChild("childDir");
+    Path linkToLinkToDirectory = absolutize("xLinkToLinkToDirectory");
+    createSymbolicLink(linkToLinkToDirectory, xLinkToDirectory);
+    Path linkToChildDir = linkToLinkToDirectory.getChild("childDir");
+
+    assertFalse(linkToChildDir.exists());
+    childDir.createDirectory();
+    assertTrue(linkToChildDir.exists());
+    assertTrue(linkToChildDir.isDirectory());
+    assertFalse(linkToChildDir.isFile());
+
+    validateLinkedReferenceObeysReadOnly(childDir, linkToChildDir);
+    validateLinkedReferenceObeysExecutable(childDir, linkToChildDir);
+  }
+
+  private void validateLinkedReferenceObeysReadOnly(Path path, Path link) throws IOException {
+    path.setWritable(false);
+    assertFalse(path.isWritable());
+    assertFalse(link.isWritable());
+    path.setWritable(true);
+    assertTrue(path.isWritable());
+    assertTrue(link.isWritable());
+    path.setWritable(false);
+    assertFalse(path.isWritable());
+    assertFalse(link.isWritable());
+  }
+
+  private void validateLinkedReferenceObeysExecutable(Path path, Path link) throws IOException {
+    path.setExecutable(true);
+    assertTrue(path.isExecutable());
+    assertTrue(link.isExecutable());
+    path.setExecutable(false);
+    assertFalse(path.isExecutable());
+    assertFalse(link.isExecutable());
+    path.setExecutable(true);
+    assertTrue(path.isExecutable());
+    assertTrue(link.isExecutable());
+  }
+
+  @Test
+  public void testReadingFileFromLinkedDirectory() throws Exception {
+    Path linkedTo = absolutize("linkedTo");
+    linkedTo.createDirectory();
+    Path child = linkedTo.getChild("child");
+    FileSystemUtils.createEmptyFile(child);
+
+    byte[] outputData = "This is a test".getBytes();
+    FileSystemUtils.writeContent(child, outputData);
+
+    Path link = absolutize("link");
+    createSymbolicLink(link, linkedTo);
+    Path linkedChild = link.getChild("child");
+    byte[] inputData = FileSystemUtils.readContent(linkedChild);
+    assertArrayEquals(outputData, inputData);
+  }
+
+  @Test
+  public void testCreatingFileInLinkedDirectory() throws Exception {
+    Path linkedTo = absolutize("linkedTo");
+    linkedTo.createDirectory();
+    Path child = linkedTo.getChild("child");
+
+    Path link = absolutize("link");
+    createSymbolicLink(link, linkedTo);
+    Path linkedChild = link.getChild("child");
+    byte[] outputData = "This is a test".getBytes();
+    FileSystemUtils.writeContent(linkedChild, outputData);
+
+    byte[] inputData = FileSystemUtils.readContent(child);
+    assertArrayEquals(outputData, inputData);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/UnionFileSystemTest.java b/src/test/java/com/google/devtools/build/lib/vfs/UnionFileSystemTest.java
new file mode 100644
index 0000000..396a9f8
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/UnionFileSystemTest.java
@@ -0,0 +1,330 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.devtools.build.lib.util.BlazeClock;
+import com.google.devtools.build.lib.util.Clock;
+import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Tests for the UnionFileSystem, both of generic FileSystem functionality
+ * (inherited) and tests of UnionFileSystem-specific behavior.
+ */
+@RunWith(JUnit4.class)
+public class UnionFileSystemTest extends SymlinkAwareFileSystemTest {
+  private XAttrInMemoryFs inDelegate;
+  private XAttrInMemoryFs outDelegate;
+  private XAttrInMemoryFs defaultDelegate;
+  private UnionFileSystem unionfs;
+
+  private static final String XATTR_VAL = "SOME_XATTR_VAL";
+  private static final String XATTR_KEY = "SOME_XATTR_KEY";
+
+  private void setupDelegateFileSystems() {
+    inDelegate = new XAttrInMemoryFs(BlazeClock.instance());
+    outDelegate = new XAttrInMemoryFs(BlazeClock.instance());
+    defaultDelegate = new XAttrInMemoryFs(BlazeClock.instance());
+
+    unionfs = createDefaultUnionFileSystem();
+  }
+
+  private UnionFileSystem createDefaultUnionFileSystem() {
+    return createDefaultUnionFileSystem(false);
+  }
+
+  private UnionFileSystem createDefaultUnionFileSystem(boolean readOnly) {
+    return new UnionFileSystem(ImmutableMap.<PathFragment, FileSystem>of(
+        new PathFragment("/in"), inDelegate,
+        new PathFragment("/out"), outDelegate),
+        defaultDelegate, readOnly);
+  }
+
+  @Override
+  protected FileSystem getFreshFileSystem() {
+    // Executed with each new test because it is called by super.setUp().
+    setupDelegateFileSystems();
+    return unionfs;
+  }
+
+  @Override
+  public void destroyFileSystem(FileSystem fileSystem) {
+    // Nothing.
+  }
+
+  // Tests of UnionFileSystem-specific behavior below.
+
+  @Test
+  public void testBasicDelegation() throws Exception {
+    unionfs = createDefaultUnionFileSystem();
+    Path fooPath = unionfs.getPath("/foo");
+    Path inPath = unionfs.getPath("/in");
+    Path outPath = unionfs.getPath("/out/in.txt");
+    assertSame(inDelegate, unionfs.getDelegate(inPath));
+    assertSame(outDelegate, unionfs.getDelegate(outPath));
+    assertSame(defaultDelegate, unionfs.getDelegate(fooPath));
+  }
+
+  @Test
+  public void testBasicXattr() throws Exception {
+    Path fooPath = unionfs.getPath("/foo");
+    Path inPath = unionfs.getPath("/in");
+    Path outPath = unionfs.getPath("/out/in.txt");
+
+    assertArrayEquals(XATTR_VAL.getBytes(UTF_8), inPath.getxattr(XATTR_KEY));
+    assertArrayEquals(XATTR_VAL.getBytes(UTF_8), outPath.getxattr(XATTR_KEY));
+    assertArrayEquals(XATTR_VAL.getBytes(UTF_8), fooPath.getxattr(XATTR_KEY));
+    assertNull(inPath.getxattr("not_key"));
+    assertNull(outPath.getxattr("not_key"));
+    assertNull(fooPath.getxattr("not_key"));
+  }
+
+  @Test
+  public void testDefaultFileSystemRequired() throws Exception {
+    try {
+      new UnionFileSystem(ImmutableMap.<PathFragment, FileSystem>of(), null);
+      fail("Able to create a UnionFileSystem with no default!");
+    } catch (NullPointerException expected) {
+      // OK - should fail in this case.
+    }
+  }
+
+  // Check for appropriate registration and lookup of delegate filesystems based
+  // on path prefixes, including non-canonical paths.
+  @Test
+  public void testPrefixDelegation() throws Exception {
+    unionfs = new UnionFileSystem(ImmutableMap.<PathFragment, FileSystem>of(
+              new PathFragment("/foo"), inDelegate,
+              new PathFragment("/foo/bar"), outDelegate), defaultDelegate);
+
+    assertSame(inDelegate, unionfs.getDelegate(unionfs.getPath("/foo/foo.txt")));
+    assertSame(outDelegate, unionfs.getDelegate(unionfs.getPath("/foo/bar/foo.txt")));
+    assertSame(inDelegate, unionfs.getDelegate(unionfs.getPath("/foo/bar/../foo.txt")));
+    assertSame(defaultDelegate, unionfs.getDelegate(unionfs.getPath("/bar/foo.txt")));
+    assertSame(defaultDelegate, unionfs.getDelegate(unionfs.getPath("/foo/bar/../..")));
+  }
+
+  // Checks that files cannot be modified when the filesystem is created
+  // read-only, even if the delegate filesystems are read/write.
+  @Test
+  public void testModificationFlag() throws Exception {
+    assertTrue(unionfs.supportsModifications());
+    Path outPath = unionfs.getPath("/out/foo.txt");
+    assertTrue(unionfs.createDirectory(outPath.getParentDirectory()));
+    OutputStream outFile = unionfs.getOutputStream(outPath);
+    outFile.write('b');
+    outFile.close();
+
+    unionfs.setExecutable(outPath, true);
+
+    // Note that this does not destroy the underlying filesystems;
+    // UnionFileSystem is just a view.
+    unionfs = createDefaultUnionFileSystem(true);
+    assertFalse(unionfs.supportsModifications());
+
+    InputStream outFileInput = unionfs.getInputStream(outPath);
+    int outFileByte = outFileInput.read();
+    outFileInput.close();
+    assertEquals('b', outFileByte);
+
+    assertTrue(unionfs.isExecutable(outPath));
+
+    // Modifying files through the unionfs isn't permitted, even if the
+    // delegates are read/write.
+    try {
+      unionfs.setExecutable(outPath, false);
+      fail("Modification to a read-only UnionFileSystem succeeded.");
+    } catch (UnsupportedOperationException expected) {
+      // OK - should fail.
+    }
+  }
+
+  // Checks that roots of delegate filesystems are created outside of the
+  // delegate filesystems; i.e. they can be seen from the filesystem of the parent.
+  @Test
+  public void testDelegateRootDirectoryCreation() throws Exception {
+    Path foo = unionfs.getPath("/foo");
+    Path bar = unionfs.getPath("/bar");
+    Path out = unionfs.getPath("/out");
+    assertTrue(unionfs.createDirectory(foo));
+    assertTrue(unionfs.createDirectory(bar));
+    assertTrue(unionfs.createDirectory(out));
+    Path outFile = unionfs.getPath("/out/in");
+    FileSystemUtils.writeContentAsLatin1(outFile, "Out");
+
+    // FileSystemTest.setUp() silently creates the test root on the filesystem...
+    Path testDirUnderRoot = unionfs.getPath(workingDir.asFragment().subFragment(0, 1));
+    assertThat(unionfs.getDirectoryEntries(unionfs.getRootDirectory())).containsExactly(foo, bar,
+        out, testDirUnderRoot);
+    assertThat(unionfs.getDirectoryEntries(out)).containsExactly(outFile);
+
+    assertSame(unionfs.getDelegate(foo), defaultDelegate);
+    assertEquals(foo.asFragment(), unionfs.adjustPath(foo, defaultDelegate).asFragment());
+    assertSame(unionfs.getDelegate(bar), defaultDelegate);
+    assertSame(unionfs.getDelegate(outFile), outDelegate);
+    assertSame(unionfs.getDelegate(out), outDelegate);
+
+    // As a fragment (i.e. without filesystem or root info), the path name should be preserved.
+    assertEquals(outFile.asFragment(), unionfs.adjustPath(outFile, outDelegate).asFragment());
+  }
+
+  // Ensure that the right filesystem is still chosen when paths contain "..".
+  @Test
+  public void testDelegationOfUpLevelReferences() throws Exception {
+    assertSame(defaultDelegate, unionfs.getDelegate(unionfs.getPath("/in/../foo.txt")));
+    assertSame(inDelegate, unionfs.getDelegate(unionfs.getPath("/out/../in")));
+    assertSame(outDelegate, unionfs.getDelegate(unionfs.getPath("/out/../in/../out/foo.txt")));
+    assertSame(inDelegate, unionfs.getDelegate(unionfs.getPath("/in/./foo.txt")));
+  }
+
+  // Basic *explicit* cross-filesystem symlink check.
+  // Note: This does not work implicitly yet, as the next test illustrates.
+  @Test
+  public void testCrossDeviceSymlinks() throws Exception {
+    assertTrue(unionfs.createDirectory(unionfs.getPath("/out")));
+
+    // Create an "/in" directory directly on the output delegate to bypass the
+    // UnionFileSystem's mapping.
+    assertTrue(inDelegate.getPath("/in").createDirectory());
+    OutputStream outStream = inDelegate.getPath("/in/bar.txt").getOutputStream();
+    outStream.write('i');
+    outStream.close();
+
+    Path outFoo = unionfs.getPath("/out/foo");
+    unionfs.createSymbolicLink(outFoo, new PathFragment("../in/bar.txt"));
+    assertTrue(unionfs.stat(outFoo, false).isSymbolicLink());
+
+    try {
+      unionfs.stat(outFoo, true).isFile();
+      fail("Stat on cross-device symlink succeeded!");
+    } catch (FileNotFoundException expected) {
+      // OK
+    }
+
+    Path resolved = unionfs.resolveSymbolicLinks(outFoo);
+    assertSame(unionfs, resolved.getFileSystem());
+    InputStream barInput = resolved.getInputStream();
+    int barChar = barInput.read();
+    barInput.close();
+    assertEquals('i', barChar);
+  }
+
+  @Test
+  public void testNoDelegateLeakage() throws Exception {
+    assertSame(unionfs, unionfs.getPath("/in/foo.txt").getFileSystem());
+    assertSame(unionfs, unionfs.getPath("/in/foo/bar").getParentDirectory().getFileSystem());
+    unionfs.createDirectory(unionfs.getPath("/out"));
+    unionfs.createDirectory(unionfs.getPath("/out/foo"));
+    unionfs.createDirectory(unionfs.getPath("/out/foo/bar"));
+    assertSame(unionfs, Iterables.getOnlyElement(unionfs.getDirectoryEntries(
+        unionfs.getPath("/out/foo"))).getParentDirectory().getFileSystem());
+  }
+
+  // Prefix mappings can apply to files starting with a prefix within a directory.
+  @Test
+  public void testWithinDirectoryMapping() throws Exception {
+    unionfs = new UnionFileSystem(ImmutableMap.<PathFragment, FileSystem>of(
+        new PathFragment("/fruit/a"), inDelegate,
+        new PathFragment("/fruit/b"), outDelegate), defaultDelegate);
+    assertTrue(unionfs.createDirectory(unionfs.getPath("/fruit")));
+    assertTrue(defaultDelegate.getPath("/fruit").isDirectory());
+    assertTrue(inDelegate.getPath("/fruit").createDirectory());
+    assertTrue(outDelegate.getPath("/fruit").createDirectory());
+
+    Path apple = unionfs.getPath("/fruit/apple");
+    Path banana = unionfs.getPath("/fruit/banana");
+    Path cherry = unionfs.getPath("/fruit/cherry");
+    unionfs.createDirectory(apple);
+    unionfs.createDirectory(banana);
+    assertSame(inDelegate, unionfs.getDelegate(apple));
+    assertSame(outDelegate, unionfs.getDelegate(banana));
+    assertSame(defaultDelegate, unionfs.getDelegate(cherry));
+
+    FileSystemUtils.writeContentAsLatin1(apple.getRelative("table"), "penny");
+    FileSystemUtils.writeContentAsLatin1(banana.getRelative("nana"), "nanana");
+    FileSystemUtils.writeContentAsLatin1(cherry, "garcia");
+
+    assertEquals("penny", new String(
+        FileSystemUtils.readContentAsLatin1(inDelegate.getPath("/fruit/apple/table"))));
+    assertEquals("nanana", new String(
+        FileSystemUtils.readContentAsLatin1(outDelegate.getPath("/fruit/banana/nana"))));
+    assertEquals("garcia", new String(
+        FileSystemUtils.readContentAsLatin1(defaultDelegate.getPath("/fruit/cherry"))));
+  }
+
+  // Write using the VFS through a UnionFileSystem and check that the file can
+  // be read back in the same location using standard Java IO.
+  // There is a similar test in UnixFileSystem, but this is essential to ensure
+  // that paths aren't being remapped in some nasty way on the underlying FS.
+  @Test
+  public void testDelegateOperationsReflectOnLocalFilesystem() throws Exception {
+    unionfs = new UnionFileSystem(ImmutableMap.<PathFragment, FileSystem>of(
+        workingDir.getParentDirectory().asFragment(), new UnixFileSystem()),
+        defaultDelegate, false);
+    // This is a child of the current tmpdir, and doesn't exist on its own.
+    // It would be created in setup(), but of course, that didn't use a UnixFileSystem.
+    unionfs.createDirectory(workingDir);
+    Path testFile = unionfs.getPath(workingDir.getRelative("test_file").asFragment());
+    assertTrue(testFile.asFragment().startsWith(workingDir.asFragment()));
+    String testString = "This is a test file";
+    FileSystemUtils.writeContentAsLatin1(testFile, testString);
+    try {
+      assertEquals(testString, new String(FileSystemUtils.readContentAsLatin1(testFile)));
+    } finally {
+      testFile.delete();
+      assertTrue(unionfs.delete(workingDir));
+    }
+  }
+
+  // Regression test for [UnionFS: Directory creation across mapping fails.]
+  @Test
+  public void testCreateParentsAcrossMapping() throws Exception {
+    unionfs = new UnionFileSystem(ImmutableMap.<PathFragment, FileSystem>of(
+        new PathFragment("/out/dir"), outDelegate), defaultDelegate, false);
+    Path outDir = unionfs.getPath("/out/dir/biz/bang");
+    FileSystemUtils.createDirectoryAndParents(outDir);
+    assertTrue(outDir.isDirectory());
+  }
+
+  private static class XAttrInMemoryFs extends InMemoryFileSystem {
+    public XAttrInMemoryFs(Clock clock) {
+      super(clock);
+    }
+
+    @Override
+    protected byte[] getxattr(Path path, String name, boolean followSymlinks) {
+      assertSame(this, path.getFileSystem());
+      return (name.equals(XATTR_KEY)) ? XATTR_VAL.getBytes(UTF_8) : null;
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/UnixFileSystemTest.java b/src/test/java/com/google/devtools/build/lib/vfs/UnixFileSystemTest.java
new file mode 100644
index 0000000..0ded404
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/UnixFileSystemTest.java
@@ -0,0 +1,63 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.fail;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.IOException;
+
+/**
+ * Tests for the {@link UnixFileSystem} class.
+ */
+@RunWith(JUnit4.class)
+public class UnixFileSystemTest extends SymlinkAwareFileSystemTest {
+
+  @Override
+  protected FileSystem getFreshFileSystem() {
+    return new UnixFileSystem();
+  }
+
+  @Override
+  public void destroyFileSystem(FileSystem fileSystem) {
+    // Nothing.
+  }
+
+  @Override
+  protected void expectNotFound(Path path) throws IOException {
+    assertNull(path.statIfFound());
+  }
+
+  // Most tests are just inherited from FileSystemTest.
+
+  @Test
+  public void testCircularSymlinkFound() throws Exception {
+    Path linkA = absolutize("link-a");
+    Path linkB = absolutize("link-b");
+    linkA.createSymbolicLink(linkB);
+    linkB.createSymbolicLink(linkA);
+    assertFalse(linkA.exists(Symlinks.FOLLOW));
+    try {
+      linkA.statIfFound(Symlinks.FOLLOW);
+      fail();
+    } catch (IOException expected) {
+      // Expected.
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/UnixPathEqualityTest.java b/src/test/java/com/google/devtools/build/lib/vfs/UnixPathEqualityTest.java
new file mode 100644
index 0000000..f5f58e2
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/UnixPathEqualityTest.java
@@ -0,0 +1,118 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * This tests how canonical paths and non-canonical paths are equal with each
+ * other, and also how paths from different filesystems behave with each other.
+ */
+@RunWith(JUnit4.class)
+public class UnixPathEqualityTest {
+
+  private FileSystem otherUnixFs;
+  private FileSystem unixFs;
+
+  @Before
+  public void setUp() throws Exception {
+    unixFs = new UnixFileSystem();
+    otherUnixFs = new UnixFileSystem();
+    assertTrue(unixFs != otherUnixFs);
+  }
+
+  private void assertTwoWayEquals(Object obj1, Object obj2) {
+    assertTrue(obj1.equals(obj2));
+    assertTrue(obj2.equals(obj1));
+    assertEquals(obj1.hashCode(), obj2.hashCode());
+  }
+
+  private void assertTwoWayNotEquals(Object obj1, Object obj2) {
+    assertFalse(obj1.equals(obj2));
+    assertFalse(obj2.equals(obj1));
+  }
+
+  @Test
+  public void testPathsAreEqualEvenIfNotCanonical() {
+    // This path is already canonical, so there's no difference between
+    // the canonical / nonCanonical path, as far as equals is concerned
+    Path nonCanonical = unixFs.getPath("/a/canonical/unix/path");
+    Path canonical = unixFs.getPath("/a/canonical/unix/path");
+    assertTwoWayEquals(nonCanonical, canonical);
+  }
+
+  @Test
+  public void testPathsAreNeverEqualWithStrings() {
+    // Make sure that paths aren't equal to plain old strings
+    Path nonCanonical = unixFs.getPath("/a/non/../canonical/unix/path");
+    Path canonical = unixFs.getPath("/a/non/../canonical/unix/path");
+    assertTwoWayNotEquals(nonCanonical, "/a/non/../canonical/unix/path");
+    assertTwoWayNotEquals(canonical, "/a/non/../canonical/unix/path");
+  }
+
+  @Test
+  public void testCanonicalPathsFromDifferentFileSystemsAreNeverEqual() {
+    Path canonical = unixFs.getPath("/canonical/path");
+    Path otherCanonical = otherUnixFs.getPath("/canonical/path");
+    assertTwoWayNotEquals(canonical, otherCanonical);
+  }
+
+  @Test
+  public void testNonCanonicalPathsFromDifferentFileSystemsAreNeverEqual() {
+    Path nonCanonical = unixFs.getPath("/non/canonical/path");
+    Path otherNonCanonical = otherUnixFs.getPath("/non/canonical/path");
+    assertTwoWayNotEquals(nonCanonical, otherNonCanonical);
+  }
+
+  @Test
+  public void testCrossFilesystemStartsWithReturnsFalse() {
+    assertFalse(unixFs.getPath("/a").startsWith(otherUnixFs.getPath("/b")));
+  }
+
+  @Test
+  public void testCrossFilesystemOperationsForbidden() throws Exception {
+    Path a = unixFs.getPath("/a");
+    Path b = otherUnixFs.getPath("/b");
+
+    try {
+      a.renameTo(b);
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e.getMessage()).contains("different filesystems");
+    }
+
+    try {
+      a.relativeTo(b);
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e.getMessage()).contains("different filesystems");
+    }
+
+    try {
+      a.createSymbolicLink(b);
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e.getMessage()).contains("different filesystems");
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/UnixPathGetParentTest.java b/src/test/java/com/google/devtools/build/lib/vfs/UnixPathGetParentTest.java
new file mode 100644
index 0000000..0f679c3
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/UnixPathGetParentTest.java
@@ -0,0 +1,87 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.devtools.build.lib.testutil.TestUtils;
+import com.google.devtools.build.lib.vfs.util.FileSystems;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.IOException;
+
+/**
+ * A test for {@link Path} in the context of {@link UnixFileSystem}.
+ */
+@RunWith(JUnit4.class)
+public class UnixPathGetParentTest {
+
+  private FileSystem unixFs;
+  private Path testRoot;
+
+  @Before
+  public void setUp() throws Exception {
+    unixFs = FileSystems.initDefaultAsNative();
+    testRoot = unixFs.getPath(TestUtils.tmpDir()).getRelative("UnixPathGetParentTest");
+    FileSystemUtils.createDirectoryAndParents(testRoot);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    FileSystemUtils.deleteTree(testRoot); // (comment out during debugging)
+  }
+
+  private Path getParent(String path) {
+    return unixFs.getPath(path).getParentDirectory();
+  }
+
+  @Test
+  public void testAbsoluteRootHasNoParent() {
+    assertEquals(null, getParent("/"));
+  }
+
+  @Test
+  public void testParentOfSimpleDirectory() {
+    assertEquals("/foo", getParent("/foo/bar").getPathString());
+  }
+
+  @Test
+  public void testParentOfDotDotInMiddleOfPathname() {
+    assertEquals("/", getParent("/foo/../bar").getPathString());
+  }
+
+  @Test
+  public void testGetPathDoesNormalizationWithoutIO() throws IOException {
+    Path tmp = testRoot.getChild("tmp");
+    Path tmpWiz = tmp.getChild("wiz");
+
+    tmp.createDirectory();
+
+    // ln -sf /tmp /tmp/wiz
+    tmpWiz.createSymbolicLink(tmp);
+
+    assertEquals(testRoot, tmp.getParentDirectory());
+
+    assertEquals(tmp, tmpWiz.getParentDirectory());
+
+    // Under UNIX, inode(/tmp/wiz/..) == inode(/).  However getPath() does not
+    // perform I/O, only string operations, so it disagrees:
+    assertEquals(tmp, tmp.getRelative(new PathFragment("wiz/..")));
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/UnixPathTest.java b/src/test/java/com/google/devtools/build/lib/vfs/UnixPathTest.java
new file mode 100644
index 0000000..b593367
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/UnixPathTest.java
@@ -0,0 +1,279 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.Lists;
+import com.google.devtools.build.lib.testutil.TestUtils;
+import com.google.devtools.build.lib.vfs.util.FileSystems;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+
+/**
+ * Tests for {@link Path}.
+ */
+@RunWith(JUnit4.class)
+public class UnixPathTest {
+
+  private FileSystem unixFs;
+  private File aDirectory;
+  private File aFile;
+  private File anotherFile;
+  private File tmpDir;
+
+  protected FileSystem getUnixFileSystem() {
+    return FileSystems.initDefaultAsNative();
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    unixFs = getUnixFileSystem();
+    tmpDir = new File(TestUtils.tmpDir(), "tmpDir");
+    tmpDir.mkdirs();
+    aDirectory = new File(tmpDir, "a_directory");
+    aDirectory.mkdirs();
+    aFile = new File(tmpDir, "a_file");
+    new FileWriter(aFile).close();
+    anotherFile = new File(aDirectory, "another_file.txt");
+    new FileWriter(anotherFile).close();
+  }
+
+  @Test
+  public void testExists() {
+    assertTrue(unixFs.getPath(aDirectory.getPath()).exists());
+    assertTrue(unixFs.getPath(aFile.getPath()).exists());
+    assertFalse(unixFs.getPath("/does/not/exist").exists());
+  }
+
+  @Test
+  public void testDirectoryEntriesForDirectory() throws IOException {
+    Collection<Path> entries =
+        unixFs.getPath(tmpDir.getPath()).getDirectoryEntries();
+    List<Path> expectedEntries = Arrays.asList(
+      unixFs.getPath(tmpDir.getPath() + "/a_file"),
+      unixFs.getPath(tmpDir.getPath() + "/a_directory"));
+
+    assertEquals(new HashSet<Object>(expectedEntries),
+        new HashSet<Object>(entries));
+  }
+
+  @Test
+  public void testDirectoryEntriesForFileThrowsException() {
+    try {
+      unixFs.getPath(aFile.getPath()).getDirectoryEntries();
+      fail("No exception thrown.");
+    } catch (IOException x) {
+      // The expected result.
+    }
+  }
+
+  @Test
+  public void testIsFileIsTrueForFile() {
+    assertTrue(unixFs.getPath(aFile.getPath()).isFile());
+  }
+
+  @Test
+  public void testIsFileIsFalseForDirectory() {
+    assertFalse(unixFs.getPath(aDirectory.getPath()).isFile());
+  }
+
+  @Test
+  public void testBaseName() {
+    assertEquals("base", unixFs.getPath("/foo/base").getBaseName());
+  }
+
+  @Test
+  public void testBaseNameRunsAfterDotDotInterpretation() {
+    assertEquals("base", unixFs.getPath("/base/foo/..").getBaseName());
+  }
+
+  @Test
+  public void testParentOfRootIsRoot() {
+    assertEquals(unixFs.getPath("/"), unixFs.getPath("/.."));
+    assertEquals(unixFs.getPath("/"), unixFs.getPath("/../../../../../.."));
+    assertEquals(unixFs.getPath("/foo"), unixFs.getPath("/../../../foo"));
+  }
+
+  @Test
+  public void testIsDirectory() {
+    assertTrue(unixFs.getPath(aDirectory.getPath()).isDirectory());
+    assertFalse(unixFs.getPath(aFile.getPath()).isDirectory());
+    assertFalse(unixFs.getPath("/does/not/exist").isDirectory());
+  }
+
+  @Test
+  public void testListNonExistingDirectoryThrowsException() {
+    try {
+      unixFs.getPath("/does/not/exist").getDirectoryEntries();
+      fail("No exception thrown.");
+    } catch (IOException ex) {
+      // success!
+    }
+  }
+
+  private void assertPathSet(Collection<Path> actual, String... expected) {
+    List<String> actualStrings = Lists.newArrayListWithCapacity(actual.size());
+
+    for (Path path : actual) {
+      actualStrings.add(path.getPathString());
+    }
+
+    assertThat(actualStrings).containsExactlyElementsIn(Arrays.asList(expected));
+  }
+
+  @Test
+  public void testGlob() throws Exception {
+    Collection<Path> textFiles = UnixGlob.forPath(unixFs.getPath(tmpDir.getPath()))
+        .addPattern("*/*.txt")
+        .globInterruptible();
+    assertEquals(1, textFiles.size());
+    Path onlyFile = textFiles.iterator().next();
+    assertEquals(unixFs.getPath(anotherFile.getPath()), onlyFile);
+
+    Collection<Path> onlyFiles =
+        UnixGlob.forPath(unixFs.getPath(tmpDir.getPath()))
+        .addPattern("*")
+        .setExcludeDirectories(true)
+        .globInterruptible();
+    assertPathSet(onlyFiles, aFile.getPath());
+
+    Collection<Path> directoriesToo =
+        UnixGlob.forPath(unixFs.getPath(tmpDir.getPath()))
+        .addPattern("*")
+        .setExcludeDirectories(false)
+        .globInterruptible();
+    assertPathSet(directoriesToo, aFile.getPath(), aDirectory.getPath());
+  }
+
+  @Test
+  public void testGetRelative() {
+    Path relative = unixFs.getPath("/foo").getChild("bar");
+    Path expected = unixFs.getPath("/foo/bar");
+    assertEquals(expected, relative);
+  }
+
+  @Test
+  public void testEqualsAndHash() {
+    Path path = unixFs.getPath("/foo/bar");
+    Path equalPath = unixFs.getPath("/foo/bar");
+    Path differentPath = unixFs.getPath("/foo/bar/baz");
+    Object differentType = new Object();
+
+    assertEquals(path.hashCode(), equalPath.hashCode());
+    assertEquals(path, equalPath);
+    assertFalse(path.equals(differentPath));
+    assertFalse(path.equals(differentType));
+  }
+
+  @Test
+  public void testLatin1ReadAndWrite() throws IOException {
+    char[] allLatin1Chars = new char[256];
+    for (int i = 0; i < 256; i++) {
+      allLatin1Chars[i] = (char) i;
+    }
+    Path path = unixFs.getPath(aFile.getPath());
+    String latin1String = new String(allLatin1Chars);
+    FileSystemUtils.writeContentAsLatin1(path, latin1String);
+    String fileContent = new String(FileSystemUtils.readContentAsLatin1(path));
+    assertEquals(fileContent, latin1String);
+  }
+
+  /**
+   * Verify that the encoding implemented by
+   * {@link FileSystemUtils#writeContentAsLatin1(Path, String)}
+   * really is 8859-1 (latin1).
+   */
+  @Test
+  public void testVerifyLatin1() throws IOException {
+    char[] allLatin1Chars = new char[256];
+    for( int i = 0; i < 256; i++) {
+      allLatin1Chars[i] = (char)i;
+    }
+    Path path = unixFs.getPath(aFile.getPath());
+    String latin1String = new String(allLatin1Chars);
+    FileSystemUtils.writeContentAsLatin1(path, latin1String);
+    byte[] bytes = FileSystemUtils.readContent(path);
+    assertEquals(new String(bytes, "ISO-8859-1"), latin1String);
+  }
+
+  @Test
+  public void testBytesReadAndWrite() throws IOException {
+    byte[] bytes = new byte[] { (byte) 0xdeadbeef, (byte) 0xdeadbeef>>8,
+                                (byte) 0xdeadbeef>>16, (byte) 0xdeadbeef>>24 };
+    Path path = unixFs.getPath(aFile.getPath());
+    FileSystemUtils.writeContent(path, bytes);
+    byte[] content = FileSystemUtils.readContent(path);
+    assertEquals(bytes.length, content.length);
+    for (int i = 0; i < bytes.length; i++) {
+      assertEquals(bytes[i], content[i]);
+    }
+  }
+
+  @Test
+  public void testInputOutputStreams() throws IOException {
+    Path path = unixFs.getPath(aFile.getPath());
+    OutputStream out = path.getOutputStream();
+    for (int i = 0; i < 256; i++) {
+      out.write(i);
+    }
+    out.close();
+    InputStream in = path.getInputStream();
+    for (int i = 0; i < 256; i++) {
+      assertEquals(i, in.read());
+    }
+    assertEquals(-1, in.read());
+    in.close();
+  }
+
+  @Test
+  public void testAbsolutePathRoot() {
+    assertEquals("/", new Path(null).toString());
+  }
+
+  @Test
+  public void testAbsolutePath() {
+    Path segment = new Path(null, "bar.txt",
+      new Path(null, "foo", new Path(null)));
+    assertEquals("/foo/bar.txt", segment.toString());
+  }
+
+  @Test
+  public void testDerivedSegmentEquality() {
+    Path absoluteSegment = new Path(null);
+
+    Path derivedNode = absoluteSegment.getChild("derivedSegment");
+    Path otherDerivedNode = absoluteSegment.getChild("derivedSegment");
+
+    assertSame(derivedNode, otherDerivedNode);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/ZipFileSystemTest.java b/src/test/java/com/google/devtools/build/lib/vfs/ZipFileSystemTest.java
new file mode 100644
index 0000000..9dc1276
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/ZipFileSystemTest.java
@@ -0,0 +1,233 @@
+// Copyright 2014 Google Inc. 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.vfs;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.Lists;
+import com.google.common.io.CharStreams;
+import com.google.devtools.build.lib.testutil.BlazeTestUtils;
+import com.google.devtools.build.lib.testutil.TestConstants;
+import com.google.devtools.build.lib.vfs.util.FileSystems;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+@RunWith(JUnit4.class)
+public class ZipFileSystemTest {
+
+  /**
+   * Expected listing of sample zip files, in alpha sorted order
+   */
+  private static final String[] LISTING = {
+    "/dir1",
+    "/dir1/file1a",
+    "/dir1/file1b",
+    "/dir2",
+    "/dir2/dir3",
+    "/dir2/dir3/dir4",
+    "/dir2/dir3/dir4/file4",
+    "/dir2/file2",
+    "/file0",
+  };
+
+  private FileSystem zipFS1;
+  private FileSystem zipFS2;
+
+  @Before
+  public void setUp() throws Exception {
+    FileSystem unixFs = FileSystems.initDefaultAsNative();
+    Path testdataDir = unixFs.getPath(BlazeTestUtils.runfilesDir()).getRelative(
+        TestConstants.JAVATESTS_ROOT + "/com/google/devtools/build/lib/vfs");
+    Path zPath1 = testdataDir.getChild("sample_with_dirs.zip");
+    Path zPath2 = testdataDir.getChild("sample_without_dirs.zip");
+    zipFS1 = new ZipFileSystem(zPath1);
+    zipFS2 = new ZipFileSystem(zPath2);
+  }
+
+  private void checkExists(FileSystem fs) {
+    assertTrue(fs.getPath("/dir2/dir3/dir4").exists());
+    assertTrue(fs.getPath("/dir2/dir3/dir4/file4").exists());
+    assertFalse(fs.getPath("/dir2/dir3/dir4/bogus").exists());
+  }
+
+  @Test
+  public void testExists() {
+    checkExists(zipFS1);
+    checkExists(zipFS2);
+  }
+
+  private void checkIsFile(FileSystem fs) {
+    assertFalse(fs.getPath("/dir2/dir3/dir4").isFile());
+    assertTrue(fs.getPath("/dir2/dir3/dir4/file4").isFile());
+    assertFalse(fs.getPath("/dir2/dir3/dir4/bogus").isFile());
+  }
+
+  @Test
+  public void testIsFile() {
+    checkIsFile(zipFS1);
+    checkIsFile(zipFS2);
+  }
+
+  private void checkIsDir(FileSystem fs) {
+    assertTrue(fs.getPath("/dir2/dir3/dir4").isDirectory());
+    assertFalse(fs.getPath("/dir2/dir3/dir4/file4").isDirectory());
+    assertFalse(fs.getPath("/bogus/mobogus").isDirectory());
+    assertFalse(fs.getPath("/bogus").isDirectory());
+  }
+
+  @Test
+  public void testIsDir() {
+    checkIsDir(zipFS1);
+    checkIsDir(zipFS2);
+  }
+
+  /**
+   * Recursively add the contents of a given path, rendered as strings, into a
+   * given list.
+   */
+  private static void listChildren(Path p, List<String> list)
+      throws IOException {
+    for (Path c : p.getDirectoryEntries()) {
+      list.add(c.getPathString());
+      if (c.isDirectory()) {
+        listChildren(c, list);
+      }
+    }
+  }
+
+  private void checkListing(FileSystem fs) throws Exception {
+    List<String> list = new ArrayList<>();
+    listChildren(fs.getRootDirectory(), list);
+    Collections.sort(list);
+    assertEquals(Lists.newArrayList(LISTING), list);
+  }
+
+  @Test
+  public void testListing() throws Exception {
+    checkListing(zipFS1);
+    checkListing(zipFS2);
+
+    // Regression test for: creation of a path (i.e. a file *name*)
+    // must not affect the result of getDirectoryEntries().
+    zipFS1.getPath("/dir1/notthere");
+    checkListing(zipFS1);
+  }
+
+  private void checkFileSize(FileSystem fs, String name, long expectedSize)
+      throws IOException {
+    assertEquals(expectedSize, fs.getPath(name).getFileSize());
+  }
+
+  @Test
+  public void testCanReadRoot() {
+    Path rootDirectory = zipFS1.getRootDirectory();
+    assertTrue(rootDirectory.isDirectory());
+  }
+
+  @Test
+  public void testFileSize() throws IOException {
+    checkFileSize(zipFS1, "/dir1/file1a", 5);
+    checkFileSize(zipFS2, "/dir1/file1a", 5);
+    checkFileSize(zipFS1, "/dir2/dir3/dir4/file4", 5000);
+    checkFileSize(zipFS2, "/dir2/dir3/dir4/file4", 5000);
+  }
+
+  private void checkCantGetFileSize(FileSystem fs, String name) {
+    try {
+      fs.getPath(name).getFileSize();
+      fail();
+    } catch (IOException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testCantGetFileSize() {
+    checkCantGetFileSize(zipFS1, "/dir2/dir3/dir4/bogus");
+    checkCantGetFileSize(zipFS2, "/dir2/dir3/dir4/bogus");
+  }
+
+  private void checkOpenFile(FileSystem fs, String name, int expectedSize)
+      throws Exception {
+    InputStream is = fs.getPath(name).getInputStream();
+    List<String> lines = CharStreams.readLines(new InputStreamReader(is, "ISO-8859-1"));
+    assertEquals(expectedSize, lines.size());
+    for (int i = 0; i < expectedSize; i++) {
+      assertEquals("body", lines.get(i));
+    }
+  }
+
+  @Test
+  public void testOpenSmallFile() throws Exception {
+    checkOpenFile(zipFS1, "/dir1/file1a", 1);
+    checkOpenFile(zipFS2, "/dir1/file1a", 1);
+  }
+
+  @Test
+  public void testOpenBigFile() throws Exception {
+    checkOpenFile(zipFS1, "/dir2/dir3/dir4/file4", 1000);
+    checkOpenFile(zipFS2, "/dir2/dir3/dir4/file4", 1000);
+  }
+
+  private void checkCantOpenFile(FileSystem fs, String name) {
+    try {
+      fs.getPath(name).getInputStream();
+      fail();
+    } catch (IOException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testCantOpenFile() throws Exception {
+    checkCantOpenFile(zipFS1, "/dir2/dir3/dir4/bogus");
+    checkCantOpenFile(zipFS2, "/dir2/dir3/dir4/bogus");
+  }
+
+  private void checkCantCreateAnything(FileSystem fs, String name)  {
+    Path p = fs.getPath(name);
+    try {
+      p.createDirectory();
+      fail();
+    } catch (Exception expected) {}
+    try {
+      FileSystemUtils.createEmptyFile(p);
+      fail();
+    } catch (Exception expected) {}
+    try {
+      p.createSymbolicLink(p);
+      fail();
+    } catch (Exception expected) {}
+  }
+
+  @Test
+  public void testCantCreateAnything() throws Exception {
+    checkCantCreateAnything(zipFS1, "/dir2/dir3/dir4/new");
+    checkCantCreateAnything(zipFS2, "/dir2/dir3/dir4/new");
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/inmemoryfs/InMemoryContentInfoTest.java b/src/test/java/com/google/devtools/build/lib/vfs/inmemoryfs/InMemoryContentInfoTest.java
new file mode 100644
index 0000000..dbdd64a
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/inmemoryfs/InMemoryContentInfoTest.java
@@ -0,0 +1,73 @@
+// Copyright 2014 Google Inc. 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.vfs.inmemoryfs;
+
+import static org.junit.Assert.fail;
+
+import com.google.devtools.build.lib.util.BlazeClock;
+import com.google.devtools.build.lib.util.Clock;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class InMemoryContentInfoTest {
+
+  private Clock clock;
+
+  @Before
+  public void setUp() throws Exception {
+    clock = BlazeClock.instance();
+  }
+
+  @Test
+  public void testDirectoryCannotAddNullChild() {
+    InMemoryDirectoryInfo directory = new InMemoryDirectoryInfo(clock);
+
+    try {
+      directory.addChild("bar", null);
+      fail("NullPointerException not thrown.");
+    } catch (NullPointerException e) {
+      // success.
+    }
+  }
+
+  @Test
+  public void testDirectoryCannotAddChildTwice() {
+    InMemoryDirectoryInfo directory = new InMemoryDirectoryInfo(clock);
+    InMemoryFileInfo otherFile = new InMemoryFileInfo(clock);
+    directory.addChild("bar", otherFile);
+
+    try {
+      directory.addChild("bar", otherFile);
+      fail("IllegalArgumentException not thrown.");
+    } catch (IllegalArgumentException e) {
+      // success.
+    }
+  }
+
+  @Test
+  public void testDirectoryRemoveNonExistingChild() {
+    InMemoryDirectoryInfo directory = new InMemoryDirectoryInfo(clock);
+    try {
+      directory.removeChild("bar");
+      fail();
+    } catch (IllegalArgumentException e) {
+      // success
+    }
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/inmemoryfs/InMemoryFileSystemTest.java b/src/test/java/com/google/devtools/build/lib/vfs/inmemoryfs/InMemoryFileSystemTest.java
new file mode 100644
index 0000000..65ea6f6
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/inmemoryfs/InMemoryFileSystemTest.java
@@ -0,0 +1,414 @@
+// Copyright 2014 Google Inc. 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.vfs.inmemoryfs;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.common.collect.Lists;
+import com.google.devtools.build.lib.testutil.TestThread;
+import com.google.devtools.build.lib.util.BlazeClock;
+import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.build.lib.vfs.ScopeEscapableFileSystemTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.charset.Charset;
+import java.util.Collection;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Tests for {@link InMemoryFileSystem}. Note that most tests are inherited
+ * from {@link ScopeEscapableFileSystemTest} and ancestors. This specific
+ * file focuses only on concurrency tests.
+ *
+ */
+@RunWith(JUnit4.class)
+public class InMemoryFileSystemTest extends ScopeEscapableFileSystemTest {
+
+  @Override
+  public FileSystem getFreshFileSystem() {
+    return new InMemoryFileSystem(BlazeClock.instance(), SCOPE_ROOT);
+  }
+
+  @Override
+  public void destroyFileSystem(FileSystem fileSystem) {
+    // Nothing.
+  }
+
+  private static final int NUM_THREADS_FOR_CONCURRENCY_TESTS = 10;
+  private static final String TEST_FILE_DATA = "data";
+
+  /**
+   * Writes the given data to the given file.
+   */
+  private static void writeToFile(Path path, String data) throws IOException {
+    OutputStream out = path.getOutputStream();
+    out.write(data.getBytes(Charset.defaultCharset()));
+    out.close();
+  }
+
+  /**
+   * Tests concurrent creation of a substantial tree hierarchy including
+   * files, directories, symlinks, file contents, and permissions.
+   */
+  @Test
+  public void testConcurrentTreeConstruction() throws Exception {
+    final int NUM_TO_WRITE = 10000;
+    final AtomicInteger baseSelector = new AtomicInteger();
+
+    // 1) Define the intended path structure.
+    class PathCreator extends TestThread {
+      @Override
+      public void runTest() throws Exception {
+        Path base = testFS.getPath("/base" + baseSelector.getAndIncrement());
+        base.createDirectory();
+
+        for (int i = 0; i < NUM_TO_WRITE; i++) {
+          Path subdir1 = base.getRelative("subdir1_" + i);
+          subdir1.createDirectory();
+          Path subdir2 = base.getRelative("subdir2_" + i);
+          subdir2.createDirectory();
+
+          Path file = base.getRelative("somefile" + i);
+          writeToFile(file, TEST_FILE_DATA);
+
+          subdir1.setReadable(true);
+          subdir2.setReadable(false);
+          file.setReadable(true);
+
+          subdir1.setWritable(false);
+          subdir2.setWritable(true);
+          file.setWritable(false);
+
+          subdir1.setExecutable(false);
+          subdir2.setExecutable(true);
+          file.setExecutable(false);
+
+          subdir1.setLastModifiedTime(100);
+          subdir2.setLastModifiedTime(200);
+          file.setLastModifiedTime(300);
+
+          Path symlink = base.getRelative("symlink" + i);
+          symlink.createSymbolicLink(file);
+        }
+      }
+    }
+
+    // 2) Construct the tree.
+    Collection<TestThread> threads =
+        Lists.newArrayListWithCapacity(NUM_THREADS_FOR_CONCURRENCY_TESTS);
+    for (int i = 0; i < NUM_THREADS_FOR_CONCURRENCY_TESTS; i++) {
+      TestThread thread = new PathCreator();
+      thread.start();
+      threads.add(thread);
+    }
+    for (TestThread thread : threads) {
+      thread.joinAndAssertState(0);
+    }
+
+    // 3) Define the validation logic.
+    class PathValidator extends TestThread {
+      @Override
+      public void runTest() throws Exception {
+        Path base = testFS.getPath("/base" + baseSelector.getAndIncrement());
+        assertTrue(base.exists());
+        assertFalse(base.getRelative("notreal").exists());
+
+        for (int i = 0; i < NUM_TO_WRITE; i++) {
+          Path subdir1 = base.getRelative("subdir1_" + i);
+          assertTrue(subdir1.exists());
+          assertTrue(subdir1.isDirectory());
+          assertTrue(subdir1.isReadable());
+          assertFalse(subdir1.isWritable());
+          assertFalse(subdir1.isExecutable());
+          assertEquals(100, subdir1.getLastModifiedTime());
+
+          Path subdir2 = base.getRelative("subdir2_" + i);
+          assertTrue(subdir2.exists());
+          assertTrue(subdir2.isDirectory());
+          assertFalse(subdir2.isReadable());
+          assertTrue(subdir2.isWritable());
+          assertTrue(subdir2.isExecutable());
+          assertEquals(200, subdir2.getLastModifiedTime());
+
+          Path file = base.getRelative("somefile" + i);
+          assertTrue(file.exists());
+          assertTrue(file.isFile());
+          assertTrue(file.isReadable());
+          assertFalse(file.isWritable());
+          assertFalse(file.isExecutable());
+          assertEquals(300, file.getLastModifiedTime());
+          BufferedReader reader = new BufferedReader(
+              new InputStreamReader(file.getInputStream(), Charset.defaultCharset()));
+          assertEquals(TEST_FILE_DATA, reader.readLine());
+          assertEquals(null, reader.readLine());
+
+          Path symlink = base.getRelative("symlink" + i);
+          assertTrue(symlink.exists());
+          assertTrue(symlink.isSymbolicLink());
+          assertEquals(file.asFragment(), symlink.readSymbolicLink());
+        }
+      }
+    }
+
+    // 4) Validate the results.
+    baseSelector.set(0);
+    threads = Lists.newArrayListWithCapacity(NUM_THREADS_FOR_CONCURRENCY_TESTS);
+    for (int i = 0; i < NUM_THREADS_FOR_CONCURRENCY_TESTS; i++) {
+      TestThread thread = new PathValidator();
+      thread.start();
+      threads.add(thread);
+    }
+    for (TestThread thread : threads) {
+      thread.joinAndAssertState(0);
+    }
+  }
+
+  /**
+   * Tests concurrent creation of many files, all within the same directory.
+   */
+  @Test
+  public void testConcurrentDirectoryConstruction() throws Exception {
+   final int NUM_TO_WRITE = 10000;
+    final AtomicInteger baseSelector = new AtomicInteger();
+
+    // 1) Define the intended path structure.
+    class PathCreator extends TestThread {
+      @Override
+      public void runTest() throws Exception {
+        final int threadId = baseSelector.getAndIncrement();
+        Path base = testFS.getPath("/common_dir");
+        base.createDirectory();
+
+        for (int i = 0; i < NUM_TO_WRITE; i++) {
+          Path file = base.getRelative("somefile_" + threadId + "_" + i);
+          writeToFile(file, TEST_FILE_DATA);
+          file.setReadable(i % 2 == 0);
+          file.setWritable(i % 3 == 0);
+          file.setExecutable(i % 4 == 0);
+          file.setLastModifiedTime(i);
+          Path symlink = base.getRelative("symlink_" + threadId + "_" + i);
+          symlink.createSymbolicLink(file);
+        }
+      }
+    }
+
+    // 2) Create the files.
+    Collection<TestThread> threads =
+        Lists.newArrayListWithCapacity(NUM_THREADS_FOR_CONCURRENCY_TESTS);
+    for (int i = 0; i < NUM_THREADS_FOR_CONCURRENCY_TESTS; i++) {
+      TestThread thread = new PathCreator();
+      thread.start();
+      threads.add(thread);
+    }
+    for (TestThread thread : threads) {
+      thread.joinAndAssertState(0);
+    }
+
+    // 3) Define the validation logic.
+    class PathValidator extends TestThread {
+      @Override
+      public void runTest() throws Exception {
+        final int threadId = baseSelector.getAndIncrement();
+        Path base = testFS.getPath("/common_dir");
+        assertTrue(base.exists());
+
+        for (int i = 0; i < NUM_TO_WRITE; i++) {
+          Path file = base.getRelative("somefile_" + threadId + "_" + i);
+          assertTrue(file.exists());
+          assertTrue(file.isFile());
+          assertEquals(i % 2 == 0, file.isReadable());
+          assertEquals(i % 3 == 0, file.isWritable());
+          assertEquals(i % 4 == 0, file.isExecutable());
+          assertEquals(i, file.getLastModifiedTime());
+          if (file.isReadable()) {
+            BufferedReader reader = new BufferedReader(
+                new InputStreamReader(file.getInputStream(), Charset.defaultCharset()));
+            assertEquals(TEST_FILE_DATA, reader.readLine());
+            assertEquals(null, reader.readLine());
+          }
+
+          Path symlink = base.getRelative("symlink_" + threadId + "_" + i);
+          assertTrue(symlink.exists());
+          assertTrue(symlink.isSymbolicLink());
+          assertEquals(file.asFragment(), symlink.readSymbolicLink());
+        }
+      }
+    }
+
+    // 4) Validate the results.
+    baseSelector.set(0);
+    threads = Lists.newArrayListWithCapacity(NUM_THREADS_FOR_CONCURRENCY_TESTS);
+    for (int i = 0; i < NUM_THREADS_FOR_CONCURRENCY_TESTS; i++) {
+      TestThread thread = new PathValidator();
+      thread.start();
+      threads.add(thread);
+    }
+    for (TestThread thread : threads) {
+      thread.joinAndAssertState(0);
+    }
+  }
+
+  /**
+   * Tests concurrent file deletion.
+   */
+  @Test
+  public void testConcurrentDeletion() throws Exception {
+    final int NUM_TO_WRITE = 10000;
+    final AtomicInteger baseSelector = new AtomicInteger();
+
+    final Path base = testFS.getPath("/base");
+    base.createDirectory();
+
+    // 1) Create a bunch of files.
+    for (int i = 0; i < NUM_TO_WRITE; i++) {
+      writeToFile(base.getRelative("file" + i), TEST_FILE_DATA);
+    }
+
+    // 2) Define our deletion strategy.
+    class FileDeleter extends TestThread {
+      @Override
+      public void runTest() throws Exception {
+        for (int i = 0; i < NUM_TO_WRITE / NUM_THREADS_FOR_CONCURRENCY_TESTS; i++) {
+          int whichFile = baseSelector.getAndIncrement();
+          Path file = base.getRelative("file" + whichFile);
+          if (whichFile % 25 != 0) {
+            assertTrue(file.delete());
+          } else {
+            // Throw another concurrent access point into the mix.
+            file.setExecutable(whichFile % 2 == 0);
+          }
+          assertFalse(base.getRelative("doesnotexist" + whichFile).delete());
+        }
+      }
+    }
+
+    // 3) Delete some files.
+    Collection<TestThread> threads =
+        Lists.newArrayListWithCapacity(NUM_THREADS_FOR_CONCURRENCY_TESTS);
+    for (int i = 0; i < NUM_THREADS_FOR_CONCURRENCY_TESTS; i++) {
+      TestThread thread = new FileDeleter();
+      thread.start();
+      threads.add(thread);
+    }
+    for (TestThread thread : threads) {
+      thread.joinAndAssertState(0);
+    }
+
+    // 4) Check the results.
+    for (int i = 0; i < NUM_TO_WRITE; i++) {
+      Path file = base.getRelative("file" + i);
+      if (i % 25 != 0) {
+        assertFalse(file.exists());
+      } else {
+        assertTrue(file.exists());
+        assertEquals(i % 2 == 0, file.isExecutable());
+      }
+    }
+  }
+
+  /**
+   * Tests concurrent file renaming.
+   */
+  @Test
+  public void testConcurrentRenaming() throws Exception {
+    final int NUM_TO_WRITE = 10000;
+    final AtomicInteger baseSelector = new AtomicInteger();
+
+    final Path base = testFS.getPath("/base");
+    base.createDirectory();
+
+    // 1) Create a bunch of files.
+    for (int i = 0; i < NUM_TO_WRITE; i++) {
+      writeToFile(base.getRelative("file" + i), TEST_FILE_DATA);
+    }
+
+    // 2) Define our renaming strategy.
+    class FileDeleter extends TestThread {
+      @Override
+      public void runTest() throws Exception {
+        for (int i = 0; i < NUM_TO_WRITE / NUM_THREADS_FOR_CONCURRENCY_TESTS; i++) {
+          int whichFile = baseSelector.getAndIncrement();
+          Path file = base.getRelative("file" + whichFile);
+          if (whichFile % 25 != 0) {
+            Path newName = base.getRelative("newname" + whichFile);
+            file.renameTo(newName);
+          } else {
+            // Throw another concurrent access point into the mix.
+            file.setExecutable(whichFile % 2 == 0);
+          }
+          assertFalse(base.getRelative("doesnotexist" + whichFile).delete());
+        }
+      }
+    }
+
+    // 3) Rename some files.
+    Collection<TestThread> threads =
+        Lists.newArrayListWithCapacity(NUM_THREADS_FOR_CONCURRENCY_TESTS);
+    for (int i = 0; i < NUM_THREADS_FOR_CONCURRENCY_TESTS; i++) {
+      TestThread thread = new FileDeleter();
+      thread.start();
+      threads.add(thread);
+    }
+    for (TestThread thread : threads) {
+      thread.joinAndAssertState(0);
+    }
+
+    // 4) Check the results.
+    for (int i = 0; i < NUM_TO_WRITE; i++) {
+      Path file = base.getRelative("file" + i);
+      if (i % 25 != 0) {
+        assertFalse(file.exists());
+        assertTrue(base.getRelative("newname" + i).exists());
+      } else {
+        assertTrue(file.exists());
+        assertEquals(i % 2 == 0, file.isExecutable());
+      }
+    }
+  }
+
+  @Test
+  public void testEloop() throws Exception {
+    Path a = testFS.getPath("/a");
+    Path b = testFS.getPath("/b");
+    a.createSymbolicLink(new PathFragment("b"));
+    b.createSymbolicLink(new PathFragment("a"));
+    try {
+      a.stat();
+    } catch (IOException e) {
+      assertEquals("/a (Too many levels of symbolic links)", e.getMessage());
+    }
+  }
+
+  @Test
+  public void testEloopSelf() throws Exception {
+    Path a = testFS.getPath("/a");
+    a.createSymbolicLink(new PathFragment("a"));
+    try {
+      a.stat();
+    } catch (IOException e) {
+      assertEquals("/a (Too many levels of symbolic links)", e.getMessage());
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/sample_with_dirs.zip b/src/test/java/com/google/devtools/build/lib/vfs/sample_with_dirs.zip
new file mode 100644
index 0000000..22ff63c
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/sample_with_dirs.zip
Binary files differ
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/sample_without_dirs.zip b/src/test/java/com/google/devtools/build/lib/vfs/sample_without_dirs.zip
new file mode 100644
index 0000000..f3ec5ab
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/sample_without_dirs.zip
Binary files differ
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/util/FileSystems.java b/src/test/java/com/google/devtools/build/lib/vfs/util/FileSystems.java
new file mode 100644
index 0000000..6a79a2b
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/util/FileSystems.java
@@ -0,0 +1,93 @@
+// Copyright 2014 Google Inc. 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.vfs.util;
+
+import com.google.devtools.build.lib.concurrent.ThreadSafety.ThreadSafe;
+import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.JavaIoFileSystem;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.build.lib.vfs.UnionFileSystem;
+import com.google.devtools.build.lib.vfs.UnixFileSystem;
+import com.google.devtools.build.lib.vfs.ZipFileSystem;
+
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * This static file system singleton manages access to a single default
+ * {@link FileSystem} instance created within the methods of this class.
+ */
+@ThreadSafe
+public final class FileSystems {
+
+  private FileSystems() {}
+
+  private static FileSystem defaultFileSystem;
+
+  /**
+   * Initializes the default {@link FileSystem} instance as a platform native
+   * (Unix) file system, creating one iff needed, and returns the instance.
+   *
+   * <p>This method is idempotent as long as the initialization is of the same
+   * type (Native/JavaIo/Union).
+   */
+  public static synchronized FileSystem initDefaultAsNative() {
+    if (!(defaultFileSystem instanceof UnixFileSystem)) {
+      defaultFileSystem = new UnixFileSystem();
+    }
+    return defaultFileSystem;
+  }
+
+  /**
+   * Initializes the default {@link FileSystem} instance as a java.io.File
+   * file system, creating one iff needed, and returns the instance.
+   *
+   * <p>This method is idempotent as long as the initialization is of the same
+   * type (Native/JavaIo/Union).
+   */
+  public static synchronized FileSystem initDefaultAsJavaIo() {
+    if (!(defaultFileSystem instanceof JavaIoFileSystem)) {
+      defaultFileSystem = new JavaIoFileSystem();
+    }
+    return defaultFileSystem;
+  }
+
+  /**
+   * Initializes the default {@link FileSystem} instance as a
+   * {@link UnionFileSystem}, creating one iff needed,
+   * and returns the instance.
+   *
+   * <p>This method is idempotent as long as the initialization is of the same
+   * type (Native/JavaIo/Union).
+   *
+   * @param prefixMapping the desired mapping of path prefixes to delegate file systems
+   * @param rootFileSystem the default file system for paths that don't match any prefix map
+   */
+  public static synchronized FileSystem initDefaultAsUnion(
+      Map<PathFragment, FileSystem> prefixMapping, FileSystem rootFileSystem) {
+    if (!(defaultFileSystem instanceof UnionFileSystem)) {
+      defaultFileSystem = new UnionFileSystem(prefixMapping, rootFileSystem);
+    }
+    return defaultFileSystem;
+  }
+
+  /**
+   * Returns a new instance of a simple {@link FileSystem} implementation that
+   * presents the contents of a zip file as a read-only file system view.
+   */
+  public static FileSystem newZipFileSystem(Path zipFile) throws IOException {
+    return new ZipFileSystem(zipFile);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/util/FsApparatus.java b/src/test/java/com/google/devtools/build/lib/vfs/util/FsApparatus.java
new file mode 100644
index 0000000..5d93351
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/vfs/util/FsApparatus.java
@@ -0,0 +1,158 @@
+// Copyright 2014 Google Inc. 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.vfs.util;
+
+import com.google.devtools.build.lib.testutil.TestUtils;
+import com.google.devtools.build.lib.util.BlazeClock;
+import com.google.devtools.build.lib.util.StringUtilities;
+import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
+
+import junit.framework.AssertionFailedError;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * Base class for a testing apparatus for a scratch filesystem.
+ */
+public class FsApparatus {
+
+  /* ---------- State that the apparatus initializes / operates on --------- */
+  protected FileSystem fileSystem = null;
+  protected Path workingDir = null;
+
+  public static FsApparatus newInMemory() {
+    return new FsApparatus();
+  }
+
+  // TestUtil.getTmpDir is slow, so cache the result here
+  private static final String TMP_DIR =
+      new File(TestUtils.tmpDir(), "bs").toString();
+
+
+  /**
+   * When using a Native file system, absolute paths will be treated as absolute paths on the unix
+   * file system, as opposed to paths relative to the backing temp directory. So for simplicity,
+   * you ought to only use relative paths for FsApparatus#file, FsApparatus#dir, and
+   * FsApparatus#path. Otherwise, be aware of the following issue
+   *
+   *   Path p1 = scratch.path(...);
+   *   Path p2 = scratch.path(p1.getPathString());
+   *
+   * We'd like the invariant that p1.equals(p2) regardless if scratch is in-memory or not, but this
+   * does not hold with our usage of Unix filesystems.
+   */
+  public static FsApparatus newNative() {
+    FileSystem fs = FileSystems.initDefaultAsNative();
+    Path wd = fs.getPath(TMP_DIR);
+
+    try {
+      FileSystemUtils.deleteTree(wd);
+    } catch (IOException e) {
+      throw new AssertionFailedError(e.getMessage());
+    }
+
+    return new FsApparatus(fs, wd);
+  }
+
+  private FsApparatus() {
+    fileSystem = new InMemoryFileSystem(BlazeClock.instance());
+    workingDir = fileSystem.getPath("/");
+  }
+
+  public FsApparatus(FileSystem fs, Path cwd) {
+    fileSystem = fs;
+    workingDir = cwd;
+  }
+
+  public FsApparatus(FileSystem fs) {
+    fileSystem = fs;
+    workingDir = fs.getPath("/");
+  }
+
+  public FileSystem fs() {
+    return fileSystem;
+  }
+
+  /**
+   * Initializes this apparatus (if it hasn't been initialized yet), and creates
+   * a scratch file in the scratch filesystem with the given {@code pathName}
+   * with {@code lines} being its content. The method returns a Path instance
+   * for the scratch file.
+   */
+  public Path file(String pathName, String... lines) throws IOException {
+    Path file = path(pathName);
+    Path parentDir = file.getParentDirectory();
+    if (!parentDir.exists()) {
+      FileSystemUtils.createDirectoryAndParents(parentDir);
+    }
+    if (file.exists()) {
+      throw new IOException("Could not create scratch file (file exists) "
+          + file);
+    }
+    String fileContent = StringUtilities.joinLines(lines);
+    FileSystemUtils.writeContentAsLatin1(file, fileContent);
+    return file;
+  }
+
+  /**
+   * Initializes this apparatus (if it hasn't been initialized yet), and creates
+   * a directory in the scratch filesystem, with the given {@code pathName}.
+   * Creates parent directories as necessary.
+   */
+  public Path dir(String pathName) throws IOException {
+    Path dir = path(pathName);
+    if (!dir.exists()) {
+      FileSystemUtils.createDirectoryAndParents(dir);
+    }
+    if (!dir.isDirectory()) {
+      throw new IOException("Exists, but is not a directory: " + dir);
+    }
+    return dir;
+  }
+
+  /**
+   * Initializes this apparatus (if it hasn't been initialized yet), and returns
+   * a path object describing a file, directory, or symlink pointed at by
+   * {@code pathName}. Note that this will not create any entity in the
+   * filesystem; i.e., the file that the object is describing may not exist in
+   * the filesystem.
+   */
+  public Path path(String pathName) {
+    return workingDir.getRelative(pathName);
+  }
+
+  /**
+   * Create a fresh directory in the system temporary directory, instead of the
+   * testing directory provided by the testing framework. This path is usually
+   * shorter than a path starting with TestUtil.getTmpDir(). We care about the
+   * length because of the path length restriction for Unix local socket files.
+   *
+   * Clients are responsible for deleting the directory after tests.
+   */
+  public Path createUnixTempDir() throws IOException {
+    if (fileSystem instanceof InMemoryFileSystem) {
+      throw new IOException("Can not create Unix temporary directories in "
+                            + "an in-memory file system");
+    }
+    File file = File.createTempFile("scratch", "tmp");
+    final Path path = fileSystem.getPath(file.getAbsolutePath());
+    path.delete();
+    path.createDirectory();
+    return path;
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/skyframe/AllTests.java b/src/test/java/com/google/devtools/build/skyframe/AllTests.java
new file mode 100644
index 0000000..4ea3691
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skyframe/AllTests.java
@@ -0,0 +1,25 @@
+// Copyright 2014 Google Inc. 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.skyframe;
+
+import com.google.devtools.build.lib.testutil.ClasspathSuite;
+
+import org.junit.runner.RunWith;
+
+/**
+ * Automatically collect the tests annotated with @RunWith in this package and all subpackages.
+ */
+@RunWith(ClasspathSuite.class)
+public class AllTests {
+}
diff --git a/src/test/java/com/google/devtools/build/skyframe/ChainedFunction.java b/src/test/java/com/google/devtools/build/skyframe/ChainedFunction.java
new file mode 100644
index 0000000..4f87bb3
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skyframe/ChainedFunction.java
@@ -0,0 +1,87 @@
+// Copyright 2014 Google Inc. 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.skyframe;
+
+import com.google.common.base.Preconditions;
+import com.google.devtools.build.skyframe.GraphTester.ValueComputer;
+import com.google.devtools.build.skyframe.ParallelEvaluator.SkyFunctionEnvironment;
+import com.google.devtools.build.skyframe.SkyFunctionException.Transience;
+
+import java.util.concurrent.CountDownLatch;
+
+import javax.annotation.Nullable;
+
+/**
+ * {@link ValueComputer} that can be chained together with others of its type to synchronize the
+ * order in which builders finish.
+ */
+final class ChainedFunction implements SkyFunction {
+  @Nullable private final SkyValue value;
+  @Nullable private final CountDownLatch notifyStart;
+  @Nullable private final CountDownLatch waitToFinish;
+  @Nullable private final CountDownLatch notifyFinish;
+  private final boolean waitForException;
+  private final Iterable<SkyKey> deps;
+
+  ChainedFunction(@Nullable CountDownLatch notifyStart, @Nullable CountDownLatch waitToFinish,
+      @Nullable CountDownLatch notifyFinish, boolean waitForException,
+      @Nullable SkyValue value, Iterable<SkyKey> deps) {
+    this.notifyStart = notifyStart;
+    this.waitToFinish = waitToFinish;
+    this.notifyFinish = notifyFinish;
+    this.waitForException = waitForException;
+    Preconditions.checkState(this.waitToFinish != null || !this.waitForException, value);
+    this.value = value;
+    this.deps = deps;
+  }
+
+  @Override
+  public SkyValue compute(SkyKey key, SkyFunction.Environment env) throws GenericFunctionException,
+      InterruptedException {
+    try {
+      if (notifyStart != null) {
+        notifyStart.countDown();
+      }
+      if (waitToFinish != null) {
+        TrackingAwaiter.waitAndMaybeThrowInterrupt(waitToFinish,
+            key + " timed out waiting to finish");
+        if (waitForException) {
+          SkyFunctionEnvironment skyEnv = (SkyFunctionEnvironment) env;
+          TrackingAwaiter.waitAndMaybeThrowInterrupt(skyEnv.getExceptionLatchForTesting(),
+              key + " timed out waiting for exception");
+        }
+      }
+      for (SkyKey dep : deps) {
+        env.getValue(dep);
+      }
+      if (value == null) {
+        throw new GenericFunctionException(new SomeErrorException("oops"),
+            Transience.PERSISTENT);
+      }
+      if (env.valuesMissing()) {
+        return null;
+      }
+      return value;
+    } finally {
+      if (notifyFinish != null) {
+        notifyFinish.countDown();
+      }
+    }
+  }
+
+  @Override
+  public String extractTag(SkyKey skyKey) {
+    throw new UnsupportedOperationException();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/skyframe/CycleDeduperTest.java b/src/test/java/com/google/devtools/build/skyframe/CycleDeduperTest.java
new file mode 100644
index 0000000..592d95f
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skyframe/CycleDeduperTest.java
@@ -0,0 +1,64 @@
+// Copyright 2014 Google Inc. 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.skyframe;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Simple tests for {@link CycleDeduper}. */
+@RunWith(JUnit4.class)
+public class CycleDeduperTest {
+
+  private CycleDeduper<String> cycleDeduper = new CycleDeduper<>();
+
+  @Test
+  public void simple() throws Exception {
+    assertTrue(cycleDeduper.seen(ImmutableList.of("a", "b")));
+    assertFalse(cycleDeduper.seen(ImmutableList.of("a", "b")));
+    assertFalse(cycleDeduper.seen(ImmutableList.of("b", "a")));
+
+    assertTrue(cycleDeduper.seen(ImmutableList.of("a", "b", "c")));
+    assertFalse(cycleDeduper.seen(ImmutableList.of("b", "c", "a")));
+    assertFalse(cycleDeduper.seen(ImmutableList.of("c", "a", "b")));
+    assertTrue(cycleDeduper.seen(ImmutableList.of("b", "a", "c")));
+    assertFalse(cycleDeduper.seen(ImmutableList.of("c", "b", "a")));
+  }
+
+  @Test
+  public void badCycle_Empty() throws Exception {
+    try {
+      cycleDeduper.seen(ImmutableList.<String>of());
+      fail();
+    } catch (IllegalStateException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void badCycle_NonUniqueMembers() throws Exception {
+    try {
+      cycleDeduper.seen(ImmutableList.<String>of("a", "b", "a"));
+      fail();
+    } catch (IllegalStateException e) {
+      // Expected.
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/skyframe/CyclesReporterTest.java b/src/test/java/com/google/devtools/build/skyframe/CyclesReporterTest.java
new file mode 100644
index 0000000..35bda02
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skyframe/CyclesReporterTest.java
@@ -0,0 +1,84 @@
+// Copyright 2014 Google Inc. 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.skyframe;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.devtools.build.lib.events.EventHandler;
+import com.google.devtools.build.lib.events.NullEventHandler;
+import com.google.devtools.build.skyframe.CyclesReporter.SingleCycleReporter;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+@RunWith(JUnit4.class)
+public class CyclesReporterTest {
+
+  private static final SkyKey DUMMY_KEY = new SkyKey(SkyFunctionName.computed("func"), "key");
+
+  @Test
+  public void nullEventHandler() {
+    CyclesReporter cyclesReporter = new CyclesReporter();
+    try {
+      cyclesReporter.reportCycles(ImmutableList.<CycleInfo>of(), DUMMY_KEY, null);
+      assertThat(false).isTrue();
+    } catch (NullPointerException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void notReportedAssertion() {
+    SingleCycleReporter singleReporter = new SingleCycleReporter() {
+      @Override
+      public boolean maybeReportCycle(SkyKey topLevelKey, CycleInfo cycleInfo,
+          boolean alreadyReported, EventHandler eventHandler) {
+        return false;
+      }
+    };
+
+    CycleInfo cycleInfo = new CycleInfo(ImmutableList.of(DUMMY_KEY));
+    CyclesReporter cyclesReporter = new CyclesReporter(singleReporter);
+    try {
+      cyclesReporter.reportCycles(ImmutableList.of(cycleInfo), DUMMY_KEY,
+          NullEventHandler.INSTANCE);
+      assertThat(false).isTrue();
+    } catch (IllegalStateException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void smoke() {
+    final AtomicBoolean reported = new AtomicBoolean();
+    SingleCycleReporter singleReporter = new SingleCycleReporter() {
+      @Override
+      public boolean maybeReportCycle(SkyKey topLevelKey, CycleInfo cycleInfo,
+          boolean alreadyReported, EventHandler eventHandler) {
+        reported.set(true);
+        return true;
+      }
+    };
+
+    CycleInfo cycleInfo = new CycleInfo(ImmutableList.of(DUMMY_KEY));
+    CyclesReporter cyclesReporter = new CyclesReporter(singleReporter);
+    cyclesReporter.reportCycles(ImmutableList.of(cycleInfo), DUMMY_KEY,
+        NullEventHandler.INSTANCE);
+    assertThat(reported.get()).isTrue();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/skyframe/DeterministicInMemoryGraph.java b/src/test/java/com/google/devtools/build/skyframe/DeterministicInMemoryGraph.java
new file mode 100644
index 0000000..8ea81d0
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skyframe/DeterministicInMemoryGraph.java
@@ -0,0 +1,71 @@
+// Copyright 2014 Google Inc. 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.skyframe;
+
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+/** {@link NotifyingInMemoryGraph} that returns reverse deps ordered alphabetically. */
+public class DeterministicInMemoryGraph extends NotifyingInMemoryGraph {
+  public DeterministicInMemoryGraph(Listener listener) {
+    super(listener);
+  }
+
+  public DeterministicInMemoryGraph() {
+    super(Listener.NULL_LISTENER);
+  }
+
+  @Override
+  protected DeterministicValueEntry getEntry(SkyKey key) {
+    return new DeterministicValueEntry(key);
+  }
+
+  /**
+   * This class uses TreeSet to store reverse dependencies of NodeEntry. As a result all values are
+   * lexicographically sorted.
+   */
+  private class DeterministicValueEntry extends NotifyingNodeEntry {
+    private DeterministicValueEntry(SkyKey myKey) {
+      super(myKey);
+    }
+
+    final Comparator<SkyKey> valueEntryComparator = new Comparator<SkyKey>() {
+      @Override
+      public int compare(SkyKey o1, SkyKey o2) {
+        return o1.toString().compareTo(o2.toString());
+      }
+    };
+    @SuppressWarnings("unchecked")
+    @Override
+    synchronized Iterable<SkyKey> getReverseDeps() {
+      TreeSet<SkyKey> result = new TreeSet<SkyKey>(valueEntryComparator);
+      if (reverseDeps instanceof List) {
+        result.addAll((Collection<? extends SkyKey>) reverseDeps);
+      } else {
+        result.add((SkyKey) reverseDeps);
+      }
+      return result;
+    }
+
+    @Override
+    synchronized Set<SkyKey> getInProgressReverseDeps() {
+      TreeSet<SkyKey> result = new TreeSet<SkyKey>(valueEntryComparator);
+      result.addAll(buildingState.getReverseDepsToSignal());
+      return result;
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/skyframe/EagerInvalidatorTest.java b/src/test/java/com/google/devtools/build/skyframe/EagerInvalidatorTest.java
new file mode 100644
index 0000000..f12754a
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skyframe/EagerInvalidatorTest.java
@@ -0,0 +1,616 @@
+// Copyright 2014 Google Inc. 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.skyframe;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.build.skyframe.GraphTester.CONCATENATE;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.common.testing.GcFinalization;
+import com.google.devtools.build.lib.events.Reporter;
+import com.google.devtools.build.lib.testutil.TestUtils;
+import com.google.devtools.build.lib.util.Pair;
+import com.google.devtools.build.skyframe.GraphTester.StringValue;
+import com.google.devtools.build.skyframe.InvalidatingNodeVisitor.DirtyingInvalidationState;
+import com.google.devtools.build.skyframe.InvalidatingNodeVisitor.InvalidationState;
+import com.google.devtools.build.skyframe.InvalidatingNodeVisitor.InvalidationType;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.experimental.runners.Enclosed;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.lang.ref.WeakReference;
+import java.util.HashSet;
+import java.util.Random;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import javax.annotation.Nullable;
+
+/**
+ * Tests for {@link InvalidatingNodeVisitor}.
+ */
+@RunWith(Enclosed.class)
+public class EagerInvalidatorTest {
+  protected InMemoryGraph graph;
+  protected GraphTester tester = new GraphTester();
+  protected InvalidationState state = newInvalidationState();
+  protected AtomicReference<InvalidatingNodeVisitor> visitor = new AtomicReference<>();
+  protected DirtyKeyTrackerImpl dirtyKeyTracker;
+
+  private IntVersion graphVersion = new IntVersion(0);
+
+  // The following three methods should be abstract, but junit4 does not allow us to run inner
+  // classes in an abstract outer class. Thus, we provide implementations. These methods will never
+  // be run because only the inner classes, annotated with @RunWith, will actually be executed.
+  EvaluationProgressReceiver.InvalidationState expectedState() {
+    throw new UnsupportedOperationException();
+  }
+
+  @SuppressWarnings("unused") // Overridden by subclasses.
+  void invalidate(DirtiableGraph graph, EvaluationProgressReceiver invalidationReceiver,
+      SkyKey... keys) throws InterruptedException { throw new UnsupportedOperationException(); }
+
+  boolean gcExpected() { throw new UnsupportedOperationException(); }
+
+  private boolean isInvalidated(SkyKey key) {
+    NodeEntry entry = graph.get(key);
+    if (gcExpected()) {
+      return entry == null;
+    } else {
+      return entry == null || entry.isDirty();
+    }
+  }
+
+  private void assertChanged(SkyKey key) {
+    NodeEntry entry = graph.get(key);
+    if (gcExpected()) {
+      assertNull(entry);
+    } else {
+      assertTrue(entry.isChanged());
+    }
+  }
+
+  private void assertDirtyAndNotChanged(SkyKey key) {
+    NodeEntry entry = graph.get(key);
+    if (gcExpected()) {
+      assertNull(entry);
+    } else {
+      assertTrue(entry.isDirty());
+      assertFalse(entry.isChanged());
+    }
+
+  }
+
+  protected InvalidationState newInvalidationState() {
+    throw new UnsupportedOperationException("Sublcasses must override");
+  }
+
+  protected InvalidationType defaultInvalidationType() {
+    throw new UnsupportedOperationException("Sublcasses must override");
+  }
+
+  // Convenience method for eval-ing a single value.
+  protected SkyValue eval(boolean keepGoing, SkyKey key) throws InterruptedException {
+    SkyKey[] keys = { key };
+    return eval(keepGoing, keys).get(key);
+  }
+
+  protected <T extends SkyValue> EvaluationResult<T> eval(boolean keepGoing, SkyKey... keys)
+    throws InterruptedException {
+    Reporter reporter = new Reporter();
+    ParallelEvaluator evaluator = new ParallelEvaluator(graph, graphVersion,
+        ImmutableMap.of(GraphTester.NODE_TYPE, tester.createDelegatingFunction()),
+        reporter, new MemoizingEvaluator.EmittedEventState(), keepGoing, 200, null,
+        new DirtyKeyTrackerImpl());
+    graphVersion = graphVersion.next();
+    return evaluator.eval(ImmutableList.copyOf(keys));
+  }
+
+  protected void invalidateWithoutError(@Nullable EvaluationProgressReceiver invalidationReceiver,
+      SkyKey... keys) throws InterruptedException {
+    invalidate(graph, invalidationReceiver, keys);
+    assertTrue(state.isEmpty());
+  }
+
+  protected void set(String name, String value) {
+    tester.set(name, new StringValue(value));
+  }
+
+  protected SkyKey skyKey(String name) {
+    return GraphTester.toSkyKeys(name)[0];
+  }
+
+  protected void assertValueValue(String name, String expectedValue) throws InterruptedException {
+    StringValue value = (StringValue) eval(false, skyKey(name));
+    assertEquals(expectedValue, value.getValue());
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    dirtyKeyTracker = new DirtyKeyTrackerImpl();
+  }
+
+  @Test
+  public void receiverWorks() throws Exception {
+    final Set<String> invalidated = Sets.newConcurrentHashSet();
+    EvaluationProgressReceiver receiver = new EvaluationProgressReceiver() {
+      @Override
+      public void invalidated(SkyValue value, InvalidationState state) {
+        Preconditions.checkState(state == expectedState());
+        invalidated.add(((StringValue) value).getValue());
+      }
+
+      @Override
+      public void enqueueing(SkyKey skyKey) {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public void evaluated(SkyKey skyKey, SkyValue value, EvaluationState state) {
+        throw new UnsupportedOperationException();
+      }
+    };
+    graph = new InMemoryGraph();
+    set("a", "a");
+    set("b", "b");
+    tester.getOrCreate("ab").addDependency("a").addDependency("b")
+        .setComputedValue(CONCATENATE);
+    assertValueValue("ab", "ab");
+
+    set("a", "c");
+    invalidateWithoutError(receiver, skyKey("a"));
+    assertThat(invalidated).containsExactly("a", "ab");
+    assertValueValue("ab", "cb");
+    set("b", "d");
+    invalidateWithoutError(receiver, skyKey("b"));
+    assertThat(invalidated).containsExactly("a", "ab", "b", "cb");
+  }
+
+  @Test
+  public void receiverIsNotNotifiedAboutValuesInError() throws Exception {
+    final Set<String> invalidated = Sets.newConcurrentHashSet();
+    EvaluationProgressReceiver receiver = new EvaluationProgressReceiver() {
+      @Override
+      public void invalidated(SkyValue value, InvalidationState state) {
+        Preconditions.checkState(state == expectedState());
+        invalidated.add(((StringValue) value).getValue());
+      }
+
+      @Override
+      public void enqueueing(SkyKey skyKey) {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public void evaluated(SkyKey skyKey, SkyValue value, EvaluationState state) {
+        throw new UnsupportedOperationException();
+      }
+    };
+
+    graph = new InMemoryGraph();
+    set("a", "a");
+    tester.getOrCreate("ab").addDependency("a").setHasError(true);
+    eval(false, skyKey("ab"));
+
+    invalidateWithoutError(receiver, skyKey("a"));
+    assertThat(invalidated).containsExactly("a").inOrder();
+  }
+
+  @Test
+  public void invalidateValuesNotInGraph() throws Exception {
+    final Set<String> invalidated = Sets.newConcurrentHashSet();
+    EvaluationProgressReceiver receiver = new EvaluationProgressReceiver() {
+      @Override
+      public void invalidated(SkyValue value, InvalidationState state) {
+        Preconditions.checkState(state == InvalidationState.DIRTY);
+        invalidated.add(((StringValue) value).getValue());
+      }
+
+      @Override
+      public void enqueueing(SkyKey skyKey) {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public void evaluated(SkyKey skyKey, SkyValue value, EvaluationState state) {
+        throw new UnsupportedOperationException();
+      }
+    };
+    graph = new InMemoryGraph();
+    invalidateWithoutError(receiver, skyKey("a"));
+    assertThat(invalidated).isEmpty();
+    set("a", "a");
+    assertValueValue("a", "a");
+    invalidateWithoutError(receiver, skyKey("b"));
+    assertThat(invalidated).isEmpty();
+  }
+
+  @Test
+  public void invalidatedValuesAreGCedAsExpected() throws Exception {
+    SkyKey key = GraphTester.skyKey("a");
+    HeavyValue heavyValue = new HeavyValue();
+    WeakReference<HeavyValue> weakRef = new WeakReference<>(heavyValue);
+    tester.set("a", heavyValue);
+
+    graph = new InMemoryGraph();
+    eval(false, key);
+    invalidate(graph, null, key);
+
+    tester = null;
+    heavyValue = null;
+    if (gcExpected()) {
+      GcFinalization.awaitClear(weakRef);
+    } else {
+      // Not a reliable check, but better than nothing.
+      System.gc();
+      Thread.sleep(300);
+      assertNotNull(weakRef.get());
+    }
+  }
+
+  @Test
+  public void reverseDepsConsistent() throws Exception {
+    graph = new InMemoryGraph();
+    set("a", "a");
+    set("b", "b");
+    set("c", "c");
+    tester.getOrCreate("ab").addDependency("a").addDependency("b").setComputedValue(CONCATENATE);
+    tester.getOrCreate("bc").addDependency("b").addDependency("c").setComputedValue(CONCATENATE);
+    tester.getOrCreate("ab_c").addDependency("ab").addDependency("c")
+        .setComputedValue(CONCATENATE);
+    eval(false, skyKey("ab_c"), skyKey("bc"));
+
+    assertThat(graph.get(skyKey("a")).getReverseDeps()).containsExactly(skyKey("ab"));
+    assertThat(graph.get(skyKey("b")).getReverseDeps()).containsExactly(skyKey("ab"), skyKey("bc"));
+    assertThat(graph.get(skyKey("c")).getReverseDeps()).containsExactly(skyKey("ab_c"),
+        skyKey("bc"));
+
+    invalidateWithoutError(null, skyKey("ab"));
+    eval(false);
+
+    // The graph values should be gone.
+    assertTrue(isInvalidated(skyKey("ab")));
+    assertTrue(isInvalidated(skyKey("abc")));
+
+    // The reverse deps to ab and ab_c should have been removed.
+    assertThat(graph.get(skyKey("a")).getReverseDeps()).isEmpty();
+    assertThat(graph.get(skyKey("b")).getReverseDeps()).containsExactly(skyKey("bc"));
+    assertThat(graph.get(skyKey("c")).getReverseDeps()).containsExactly(skyKey("bc"));
+  }
+
+  @Test
+  public void interruptChild() throws Exception {
+    graph = new InMemoryGraph();
+    int numValues = 50; // More values than the invalidator has threads.
+    final SkyKey[] family = new SkyKey[numValues];
+    final SkyKey child = GraphTester.skyKey("child");
+    final StringValue childValue = new StringValue("child");
+    tester.set(child, childValue);
+    family[0] = child;
+    for (int i = 1; i < numValues; i++) {
+      SkyKey member = skyKey(Integer.toString(i));
+      tester.getOrCreate(member).addDependency(family[i - 1]).setComputedValue(CONCATENATE);
+      family[i] = member;
+    }
+    SkyKey parent = GraphTester.skyKey("parent");
+    tester.getOrCreate(parent).addDependency(family[numValues - 1]).setComputedValue(CONCATENATE);
+    eval(/*keepGoing=*/false, parent);
+    final Thread mainThread = Thread.currentThread();
+    final AtomicReference<SkyValue> badValue = new AtomicReference<>();
+    EvaluationProgressReceiver receiver = new EvaluationProgressReceiver() {
+      @Override
+      public void invalidated(SkyValue value, InvalidationState state) {
+        if (value == childValue) {
+          // Interrupt on the very first invalidate
+          mainThread.interrupt();
+        } else if (!childValue.equals(value)) {
+          // All other invalidations should be of the same value.
+          // Exceptions thrown here may be silently dropped, so keep track of errors ourselves.
+          badValue.set(value);
+        }
+        try {
+          assertTrue(visitor.get().awaitInterruptionForTestingOnly(2, TimeUnit.HOURS));
+        } catch (InterruptedException e) {
+          // We may well have thrown here because by the time we try to await, the main thread is
+          // already interrupted.
+        }
+      }
+
+      @Override
+      public void enqueueing(SkyKey skyKey) {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public void evaluated(SkyKey skyKey, SkyValue value, EvaluationState state) {
+        throw new UnsupportedOperationException();
+      }
+    };
+    try {
+      invalidateWithoutError(receiver, child);
+      fail();
+    } catch (InterruptedException e) {
+      // Expected.
+    }
+    assertNull(badValue.get());
+    assertFalse(state.isEmpty());
+    final Set<SkyValue> invalidated = Sets.newConcurrentHashSet();
+    assertFalse(isInvalidated(parent));
+    SkyValue parentValue = graph.getValue(parent);
+    assertNotNull(parentValue);
+    receiver = new EvaluationProgressReceiver() {
+      @Override
+      public void invalidated(SkyValue value, InvalidationState state) {
+        invalidated.add(value);
+      }
+
+      @Override
+      public void enqueueing(SkyKey skyKey) {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public void evaluated(SkyKey skyKey, SkyValue value, EvaluationState state) {
+        throw new UnsupportedOperationException();
+      }
+    };
+    invalidateWithoutError(receiver);
+    assertTrue(invalidated.contains(parentValue));
+    assertThat(state.getInvalidationsForTesting()).isEmpty();
+
+    // Regression test coverage:
+    // "all pending values are marked changed on interrupt".
+    assertTrue(isInvalidated(child));
+    assertChanged(child);
+    for (int i = 1; i < numValues; i++) {
+      assertDirtyAndNotChanged(family[i]);
+    }
+    assertDirtyAndNotChanged(parent);
+  }
+
+  private SkyKey[] constructLargeGraph(int size) {
+    Random random = new Random(TestUtils.getRandomSeed());
+    SkyKey[] values = new SkyKey[size];
+    for (int i = 0; i < size; i++) {
+      String iString = Integer.toString(i);
+      SkyKey iKey = GraphTester.toSkyKey(iString);
+      set(iString, iString);
+      for (int j = 0; j < i; j++) {
+        if (random.nextInt(3) == 0) {
+          tester.getOrCreate(iKey).addDependency(Integer.toString(j));
+        }
+      }
+      values[i] = iKey;
+    }
+    return values;
+  }
+
+  /** Returns a subset of {@code nodes} that are still valid and so can be invalidated. */
+  private Set<Pair<SkyKey, InvalidationType>> getValuesToInvalidate(SkyKey[] nodes) {
+    Set<Pair<SkyKey, InvalidationType>> result = new HashSet<>();
+    Random random = new Random(TestUtils.getRandomSeed());
+    for (SkyKey node : nodes) {
+      if (!isInvalidated(node)) {
+        if (result.isEmpty() || random.nextInt(3) == 0) {
+          // Add at least one node, if we can.
+          result.add(Pair.of(node, defaultInvalidationType()));
+        }
+      }
+    }
+    return result;
+  }
+
+  @Test
+  public void interruptThreadInReceiver() throws Exception {
+    Random random = new Random(TestUtils.getRandomSeed());
+    int graphSize = 1000;
+    int tries = 5;
+    graph = new InMemoryGraph();
+    SkyKey[] values = constructLargeGraph(graphSize);
+    eval(/*keepGoing=*/false, values);
+    final Thread mainThread = Thread.currentThread();
+    for (int run = 0; run < tries; run++) {
+      Set<Pair<SkyKey, InvalidationType>> valuesToInvalidate = getValuesToInvalidate(values);
+      // Find how many invalidations will actually be enqueued for invalidation in the first round,
+      // so that we can interrupt before all of them are done.
+      int validValuesToDo =
+          Sets.difference(valuesToInvalidate, state.getInvalidationsForTesting()).size();
+      for (Pair<SkyKey, InvalidationType> pair : state.getInvalidationsForTesting()) {
+        if (!isInvalidated(pair.first)) {
+          validValuesToDo++;
+        }
+      }
+      int countDownStart = validValuesToDo > 0 ? random.nextInt(validValuesToDo) : 0;
+      final CountDownLatch countDownToInterrupt = new CountDownLatch(countDownStart);
+      final EvaluationProgressReceiver receiver = new EvaluationProgressReceiver() {
+        @Override
+        public void invalidated(SkyValue value, InvalidationState state) {
+          countDownToInterrupt.countDown();
+          if (countDownToInterrupt.getCount() == 0) {
+            mainThread.interrupt();
+            try {
+              // Wait for the main thread to be interrupted uninterruptibly, because the main thread
+              // is going to interrupt us, and we don't want to get into an interrupt fight. Only
+              // if we get interrupted without the main thread also being interrupted will this
+              // throw an InterruptedException.
+              TrackingAwaiter.waitAndMaybeThrowInterrupt(
+                  visitor.get().getInterruptionLatchForTestingOnly(),
+                  "Main thread was not interrupted");
+            } catch (InterruptedException e) {
+              throw new IllegalStateException(e);
+            }
+          }
+        }
+
+        @Override
+        public void enqueueing(SkyKey skyKey) {
+          throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void evaluated(SkyKey skyKey, SkyValue value, EvaluationState state) {
+          throw new UnsupportedOperationException();
+        }
+      };
+      try {
+        invalidate(graph, receiver,
+            Sets.newHashSet(
+                Iterables.transform(valuesToInvalidate,
+                    Pair.<SkyKey, InvalidationType>firstFunction())).toArray(new SkyKey[0]));
+        assertThat(state.getInvalidationsForTesting()).isEmpty();
+      } catch (InterruptedException e) {
+        // Expected.
+      }
+      if (state.isEmpty()) {
+        // Ran out of values to invalidate.
+        break;
+      }
+    }
+
+    eval(/*keepGoing=*/false, values);
+  }
+
+  protected void setupInvalidatableGraph() throws Exception {
+    graph = new InMemoryGraph();
+    set("a", "a");
+    set("b", "b");
+    tester.getOrCreate("ab").addDependency("a").addDependency("b").setComputedValue(CONCATENATE);
+    assertValueValue("ab", "ab");
+    set("a", "c");
+  }
+
+  private static class HeavyValue implements SkyValue {
+  }
+
+  /**
+   * Test suite for the deleting invalidator.
+   */
+  @RunWith(JUnit4.class)
+  public static class DeletingInvalidatorTest extends EagerInvalidatorTest {
+    @Override
+    protected void invalidate(DirtiableGraph graph, EvaluationProgressReceiver invalidationReceiver,
+        SkyKey... keys) throws InterruptedException {
+      InvalidatingNodeVisitor invalidatingVisitor =
+          EagerInvalidator.createVisitor(/*delete=*/true, graph, ImmutableList.copyOf(keys),
+              invalidationReceiver, state, true, dirtyKeyTracker);
+      if (invalidatingVisitor != null) {
+        visitor.set(invalidatingVisitor);
+        invalidatingVisitor.run();
+      }
+    }
+
+    @Override
+    EvaluationProgressReceiver.InvalidationState expectedState() {
+      return EvaluationProgressReceiver.InvalidationState.DELETED;
+    }
+
+    @Override
+    boolean gcExpected() {
+      return true;
+    }
+
+    @Override
+    protected InvalidationState newInvalidationState() {
+      return new InvalidatingNodeVisitor.DeletingInvalidationState();
+    }
+
+    @Override
+    protected InvalidationType defaultInvalidationType() {
+      return InvalidationType.DELETED;
+    }
+
+    @Test
+    public void dirtyKeyTrackerWorksWithDeletingInvalidator() throws Exception {
+      setupInvalidatableGraph();
+      TrackingInvalidationReceiver receiver = new TrackingInvalidationReceiver();
+
+      // Dirty the node, and ensure that the tracker is aware of it:
+      InvalidatingNodeVisitor dirtyingVisitor =
+          EagerInvalidator.createVisitor(/*delete=*/false, graph, ImmutableList.of(skyKey("a")),
+              receiver, new DirtyingInvalidationState(), true, dirtyKeyTracker);
+      dirtyingVisitor.run();
+      assertThat(dirtyKeyTracker.getDirtyKeys()).containsExactly(skyKey("a"), skyKey("ab"));
+
+      // Delete the node, and ensure that the tracker is no longer tracking it:
+      InvalidatingNodeVisitor deletingVisitor =
+          EagerInvalidator.createVisitor(/*delete=*/true, graph, ImmutableList.of(skyKey("a")),
+              receiver, state, true, dirtyKeyTracker);
+      deletingVisitor.run();
+      assertThat(dirtyKeyTracker.getDirtyKeys()).containsExactly(skyKey("ab"));
+    }
+  }
+
+  /**
+   * Test suite for the dirtying invalidator.
+   */
+  @RunWith(JUnit4.class)
+  public static class DirtyingInvalidatorTest extends EagerInvalidatorTest {
+    @Override
+    protected void invalidate(DirtiableGraph graph, EvaluationProgressReceiver invalidationReceiver,
+        SkyKey... keys) throws InterruptedException {
+      InvalidatingNodeVisitor invalidatingVisitor =
+          EagerInvalidator.createVisitor(/*delete=*/false, graph, ImmutableList.copyOf(keys),
+              invalidationReceiver, state, true, dirtyKeyTracker);
+      if (invalidatingVisitor != null) {
+        visitor.set(invalidatingVisitor);
+        invalidatingVisitor.run();
+      }
+    }
+
+    @Override
+    EvaluationProgressReceiver.InvalidationState expectedState() {
+      return EvaluationProgressReceiver.InvalidationState.DIRTY;
+    }
+
+    @Override
+    boolean gcExpected() {
+      return false;
+    }
+
+    @Override
+    protected InvalidationState newInvalidationState() {
+      return new DirtyingInvalidationState();
+    }
+
+    @Override
+    protected InvalidationType defaultInvalidationType() {
+      return InvalidationType.CHANGED;
+    }
+
+    @Test
+    public void dirtyKeyTrackerWorksWithDirtyingInvalidator() throws Exception {
+      setupInvalidatableGraph();
+      TrackingInvalidationReceiver receiver = new TrackingInvalidationReceiver();
+
+      // Dirty the node, and ensure that the tracker is aware of it:
+      invalidate(graph, receiver, skyKey("a"));
+      assertThat(dirtyKeyTracker.getDirtyKeys()).hasSize(2);
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/skyframe/GenericFunctionException.java b/src/test/java/com/google/devtools/build/skyframe/GenericFunctionException.java
new file mode 100644
index 0000000..667f213
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skyframe/GenericFunctionException.java
@@ -0,0 +1,27 @@
+// Copyright 2014 Google Inc. 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.skyframe;
+
+/**
+ * A {@link SkyFunctionException} wrapping a {@link SomeErrorException}.
+ */
+public final class GenericFunctionException extends SkyFunctionException {
+  public GenericFunctionException(SomeErrorException e, Transience transience) {
+    super(e, transience);
+  }
+
+  public GenericFunctionException(SomeErrorException e, SkyKey childKey) {
+    super(e, childKey);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/skyframe/GraphTester.java b/src/test/java/com/google/devtools/build/skyframe/GraphTester.java
new file mode 100644
index 0000000..6095bb8
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skyframe/GraphTester.java
@@ -0,0 +1,340 @@
+// Copyright 2014 Google Inc. 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.skyframe;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.Iterables;
+import com.google.devtools.build.lib.events.Event;
+import com.google.devtools.build.lib.util.Pair;
+import com.google.devtools.build.skyframe.SkyFunctionException.Transience;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import javax.annotation.Nullable;
+
+/**
+ * A helper class to create graphs and run skyframe tests over these graphs.
+ *
+ * <p>There are two types of values, computing values, which may not be set to a constant value,
+ * and leaf values, which must be set to a constant value and may not have any dependencies.
+ *
+ * <p>Note that the value builder looks into the test values created here to determine how to
+ * behave. However, skyframe will only re-evaluate the value and call the value builder if any of
+ * its dependencies has changed. That means in order to change the set of dependencies of a value,
+ * you need to also change one of its previous dependencies to force re-evaluation. Changing a
+ * computing value does not mark it as modified.
+ */
+public class GraphTester {
+
+  // TODO(bazel-team): Split this for computing and non-computing values?
+  public static final SkyFunctionName NODE_TYPE = new SkyFunctionName("Type", false);
+
+  private final Map<SkyKey, TestFunction> values = new HashMap<>();
+  private final Set<SkyKey> modifiedValues = new LinkedHashSet<>();
+
+  public TestFunction getOrCreate(String name) {
+    return getOrCreate(skyKey(name));
+  }
+
+  public TestFunction getOrCreate(SkyKey key) {
+    return getOrCreate(key, false);
+  }
+
+  public TestFunction getOrCreate(SkyKey key, boolean markAsModified) {
+    TestFunction result = values.get(key);
+    if (result == null) {
+      result = new TestFunction();
+      values.put(key, result);
+    } else if (markAsModified) {
+      modifiedValues.add(key);
+    }
+    return result;
+  }
+
+  public TestFunction set(String key, SkyValue value) {
+    return set(skyKey(key), value);
+  }
+
+  public TestFunction set(SkyKey key, SkyValue value) {
+    return getOrCreate(key, true).setConstantValue(value);
+  }
+
+  public Collection<SkyKey> getModifiedValues() {
+    return modifiedValues;
+  }
+
+  public SkyFunction getFunction() {
+    return new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey key, Environment env)
+          throws SkyFunctionException, InterruptedException {
+        TestFunction builder = values.get(key);
+        Preconditions.checkState(builder != null, "No TestFunction for " + key);
+        if (builder.builder != null) {
+          return builder.builder.compute(key, env);
+        }
+        if (builder.warning != null) {
+          env.getListener().handle(Event.warn(builder.warning));
+        }
+        if (builder.progress != null) {
+          env.getListener().handle(Event.progress(builder.progress));
+        }
+        Map<SkyKey, SkyValue> deps = new LinkedHashMap<>();
+        boolean oneMissing = false;
+        for (Pair<SkyKey, SkyValue> dep : builder.deps) {
+          SkyValue value;
+          if (dep.second == null) {
+            value = env.getValue(dep.first);
+          } else {
+            try {
+              value = env.getValueOrThrow(dep.first, SomeErrorException.class);
+            } catch (SomeErrorException e) {
+              value = dep.second;
+            }
+          }
+          if (value == null) {
+            oneMissing = true;
+          } else {
+            deps.put(dep.first, value);
+          }
+          Preconditions.checkState(oneMissing == env.valuesMissing());
+        }
+        if (env.valuesMissing()) {
+          return null;
+        }
+
+        if (builder.hasTransientError) {
+          throw new GenericFunctionException(new SomeErrorException(key.toString()),
+              Transience.TRANSIENT);
+        }
+        if (builder.hasError) {
+          throw new GenericFunctionException(new SomeErrorException(key.toString()),
+              Transience.PERSISTENT);
+        }
+
+        if (builder.value != null) {
+          return builder.value;
+        }
+
+        if (Thread.currentThread().isInterrupted()) {
+          throw new InterruptedException(key.toString());
+        }
+
+        return builder.computer.compute(deps, env);
+      }
+
+      @Nullable
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        return values.get(skyKey).tag;
+      }
+    };
+  }
+
+  public static SkyKey skyKey(String key) {
+    return new SkyKey(NODE_TYPE, key);
+  }
+
+  /**
+   * A value in the testing graph that is constructed in the tester.
+   */
+  public class TestFunction {
+    // TODO(bazel-team): We could use a multiset here to simulate multi-pass dependency discovery.
+    private final Set<Pair<SkyKey, SkyValue>> deps = new LinkedHashSet<>();
+    private SkyValue value;
+    private ValueComputer computer;
+    private SkyFunction builder = null;
+
+    private boolean hasTransientError;
+    private boolean hasError;
+
+    private String warning;
+    private String progress;
+
+    private String tag;
+
+    public TestFunction addDependency(String name) {
+      return addDependency(skyKey(name));
+    }
+
+    public TestFunction addDependency(SkyKey key) {
+      deps.add(Pair.<SkyKey, SkyValue>of(key, null));
+      return this;
+    }
+
+    public TestFunction removeDependency(String name) {
+      return removeDependency(skyKey(name));
+    }
+
+    public TestFunction removeDependency(SkyKey key) {
+      deps.remove(Pair.<SkyKey, SkyValue>of(key, null));
+      return this;
+    }
+
+    public TestFunction addErrorDependency(String name, SkyValue altValue) {
+      return addErrorDependency(skyKey(name), altValue);
+    }
+
+    public TestFunction addErrorDependency(SkyKey key, SkyValue altValue) {
+      deps.add(Pair.of(key, altValue));
+      return this;
+    }
+
+    public TestFunction setConstantValue(SkyValue value) {
+      Preconditions.checkState(this.computer == null);
+      this.value = value;
+      return this;
+    }
+
+    public TestFunction setComputedValue(ValueComputer computer) {
+      Preconditions.checkState(this.value == null);
+      this.computer = computer;
+      return this;
+    }
+
+    public TestFunction setBuilder(SkyFunction builder) {
+      Preconditions.checkState(this.value == null);
+      Preconditions.checkState(this.computer == null);
+      Preconditions.checkState(deps.isEmpty());
+      Preconditions.checkState(!hasTransientError);
+      Preconditions.checkState(!hasError);
+      Preconditions.checkState(warning == null);
+      Preconditions.checkState(progress == null);
+      this.builder = builder;
+      return this;
+    }
+
+    public TestFunction setHasTransientError(boolean hasError) {
+      this.hasTransientError = hasError;
+      return this;
+    }
+
+    public TestFunction setHasError(boolean hasError) {
+      // TODO(bazel-team): switch to an enum for hasError.
+      this.hasError = hasError;
+      return this;
+    }
+
+    public TestFunction setWarning(String warning) {
+      this.warning = warning;
+      return this;
+    }
+
+    public TestFunction setProgress(String info) {
+      this.progress = info;
+      return this;
+    }
+
+    public TestFunction setTag(String tag) {
+      this.tag = tag;
+      return this;
+    }
+
+  }
+
+  public static SkyKey[] toSkyKeys(String... names) {
+    SkyKey[] result = new SkyKey[names.length];
+    for (int i = 0; i < names.length; i++) {
+      result[i] = new SkyKey(GraphTester.NODE_TYPE, names[i]);
+    }
+    return result;
+  }
+
+  public static SkyKey toSkyKey(String name) {
+    return toSkyKeys(name)[0];
+  }
+
+  private class DelegatingFunction implements SkyFunction {
+    @Override
+    public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException,
+        InterruptedException {
+      return getFunction().compute(skyKey, env);
+    }
+
+    @Nullable
+    @Override
+    public String extractTag(SkyKey skyKey) {
+      return getFunction().extractTag(skyKey);
+    }
+  }
+
+  public DelegatingFunction createDelegatingFunction() {
+    return new DelegatingFunction();
+  }
+
+  /**
+   * Simple value class that stores strings.
+   */
+  public static class StringValue implements SkyValue {
+    private final String value;
+
+    public StringValue(String value) {
+      this.value = value;
+    }
+
+    public String getValue() {
+      return value;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof StringValue)) {
+        return false;
+      }
+      return value.equals(((StringValue) o).value);
+    }
+
+    @Override
+    public int hashCode() {
+      return value.hashCode();
+    }
+
+    @Override
+    public String toString() {
+      return "StringValue: " + getValue();
+    }
+  }
+
+  /**
+   * A callback interface to provide the value computation.
+   */
+  public interface ValueComputer {
+    /** This is called when all the declared dependencies exist. It may request new dependencies. */
+    SkyValue compute(Map<SkyKey, SkyValue> deps, SkyFunction.Environment env)
+        throws InterruptedException;
+  }
+
+  public static final ValueComputer COPY = new ValueComputer() {
+    @Override
+    public SkyValue compute(Map<SkyKey, SkyValue> deps, SkyFunction.Environment env) {
+      return Iterables.getOnlyElement(deps.values());
+    }
+  };
+
+  public static final ValueComputer CONCATENATE = new ValueComputer() {
+    @Override
+    public SkyValue compute(Map<SkyKey, SkyValue> deps, SkyFunction.Environment env) {
+      StringBuilder result = new StringBuilder();
+      for (SkyValue value : deps.values()) {
+        result.append(((StringValue) value).value);
+      }
+      return new StringValue(result.toString());
+    }
+  };
+}
diff --git a/src/test/java/com/google/devtools/build/skyframe/MemoizingEvaluatorTest.java b/src/test/java/com/google/devtools/build/skyframe/MemoizingEvaluatorTest.java
new file mode 100644
index 0000000..00df824
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skyframe/MemoizingEvaluatorTest.java
@@ -0,0 +1,2914 @@
+// Copyright 2014 Google Inc. 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.skyframe;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.devtools.build.skyframe.GraphTester.CONCATENATE;
+import static com.google.devtools.build.skyframe.GraphTester.COPY;
+import static com.google.devtools.build.skyframe.GraphTester.NODE_TYPE;
+import static com.google.devtools.build.skyframe.GraphTester.skyKey;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.base.Preconditions;
+import com.google.common.base.Predicates;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.testing.GcFinalization;
+import com.google.common.util.concurrent.Uninterruptibles;
+import com.google.devtools.build.lib.events.DelegatingEventHandler;
+import com.google.devtools.build.lib.events.Event;
+import com.google.devtools.build.lib.events.EventCollector;
+import com.google.devtools.build.lib.events.EventHandler;
+import com.google.devtools.build.lib.events.EventKind;
+import com.google.devtools.build.lib.events.Reporter;
+import com.google.devtools.build.lib.testutil.JunitTestUtils;
+import com.google.devtools.build.lib.testutil.TestThread;
+import com.google.devtools.build.lib.testutil.TestUtils;
+import com.google.devtools.build.skyframe.GraphTester.StringValue;
+import com.google.devtools.build.skyframe.GraphTester.TestFunction;
+import com.google.devtools.build.skyframe.GraphTester.ValueComputer;
+import com.google.devtools.build.skyframe.NotifyingInMemoryGraph.EventType;
+import com.google.devtools.build.skyframe.NotifyingInMemoryGraph.Listener;
+import com.google.devtools.build.skyframe.NotifyingInMemoryGraph.Order;
+import com.google.devtools.build.skyframe.SkyFunctionException.Transience;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import javax.annotation.Nullable;
+
+/**
+ * Tests for {@link MemoizingEvaluator}.
+ */
+@RunWith(JUnit4.class)
+public class MemoizingEvaluatorTest {
+
+  private MemoizingEvaluatorTester tester;
+  private EventCollector eventCollector;
+  private EventHandler reporter;
+  private MemoizingEvaluator.EmittedEventState emittedEventState;
+
+  // Knobs that control the size / duration of larger tests.
+  private static final int TEST_NODE_COUNT = 100;
+  private static final int TESTED_NODES = 10;
+  private static final int RUNS = 10;
+
+  @Before
+  public void initializeTester() {
+    initializeTester(null);
+  }
+
+  public void initializeTester(@Nullable TrackingInvalidationReceiver customInvalidationReceiver) {
+    emittedEventState = new MemoizingEvaluator.EmittedEventState();
+    tester = new MemoizingEvaluatorTester();
+    if (customInvalidationReceiver != null) {
+      tester.setInvalidationReceiver(customInvalidationReceiver);
+    }
+    tester.initialize();
+  }
+
+  @Before
+  public void initializeReporter() {
+    eventCollector = new EventCollector(EventKind.ALL_EVENTS);
+    reporter = new Reporter(eventCollector);
+    tester.resetPlayedEvents();
+  }
+
+  protected static SkyKey toSkyKey(String name) {
+    return new SkyKey(NODE_TYPE, name);
+  }
+
+  @Test
+  public void smoke() throws Exception {
+    tester.set("x", new StringValue("y"));
+    StringValue value = (StringValue) tester.evalAndGet("x");
+    assertEquals("y", value.getValue());
+  }
+
+  @Test
+  public void invalidationWithNothingChanged() throws Exception {
+    tester.set("x", new StringValue("y")).setWarning("fizzlepop");
+    StringValue value = (StringValue) tester.evalAndGet("x");
+    assertEquals("y", value.getValue());
+    JunitTestUtils.assertContainsEvent(eventCollector, "fizzlepop");
+    JunitTestUtils.assertEventCount(1, eventCollector);
+
+    initializeReporter();
+    tester.invalidate();
+    value = (StringValue) tester.evalAndGet("x");
+    assertEquals("y", value.getValue());
+    JunitTestUtils.assertContainsEvent(eventCollector, "fizzlepop");
+    JunitTestUtils.assertEventCount(1, eventCollector);
+  }
+
+  private abstract static class NoExtractorFunction implements SkyFunction {
+    @Override
+    public final String extractTag(SkyKey skyKey) {
+      return null;
+    }
+  }
+
+  @Test
+  // Regression test for bug: "[skyframe-m1]: registerIfDone() crash".
+  public void bubbleRace() throws Exception {
+    // The top-level value declares dependencies on a "badValue" in error, and a "sleepyValue"
+    // which is very slow. After "badValue" fails, the builder interrupts the "sleepyValue" and
+    // attempts to re-run "top" for error bubbling. Make sure this doesn't cause a precondition
+    // failure because "top" still has an outstanding dep ("sleepyValue").
+    tester.getOrCreate("top").setBuilder(new NoExtractorFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws InterruptedException {
+        env.getValue(toSkyKey("sleepyValue"));
+        try {
+          env.getValueOrThrow(toSkyKey("badValue"), SomeErrorException.class);
+        } catch (SomeErrorException e) {
+          // In order to trigger this bug, we need to request a dep on an already computed value.
+          env.getValue(toSkyKey("otherValue1"));
+        }
+        if (!env.valuesMissing()) {
+          throw new AssertionError("SleepyValue should always be unavailable");
+        }
+        return null;
+      }
+    });
+    tester.getOrCreate("sleepyValue").setBuilder(new NoExtractorFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws InterruptedException {
+        Thread.sleep(99999);
+        throw new AssertionError("I should have been interrupted");
+      }
+    });
+    tester.getOrCreate("badValue").addDependency("otherValue1").setHasError(true);
+    tester.getOrCreate("otherValue1").setConstantValue(new StringValue("otherVal1"));
+
+    EvaluationResult<SkyValue> result = tester.eval(false, "top");
+    assertTrue(result.hasError());
+    assertEquals(toSkyKey("badValue"), Iterables.getOnlyElement(result.getError().getRootCauses()));
+    assertThat(result.keyNames()).isEmpty();
+  }
+
+  @Test
+  public void deleteValues() throws Exception {
+    tester.getOrCreate("top").setComputedValue(CONCATENATE)
+        .addDependency("d1").addDependency("d2").addDependency("d3");
+    tester.set("d1", new StringValue("1"));
+    StringValue d2 = new StringValue("2");
+    tester.set("d2", d2);
+    StringValue d3 = new StringValue("3");
+    tester.set("d3", d3);
+    tester.eval(true, "top");
+
+    tester.delete("d1");
+    tester.eval(true, "d3");
+
+    assertThat(tester.getDirtyValues()).isEmpty();
+    assertEquals(
+        ImmutableSet.of(new StringValue("1"), new StringValue("123")), tester.getDeletedValues());
+    assertEquals(null, tester.getExistingValue("top"));
+    assertEquals(null, tester.getExistingValue("d1"));
+    assertEquals(d2, tester.getExistingValue("d2"));
+    assertEquals(d3, tester.getExistingValue("d3"));
+  }
+
+  @Test
+  public void deleteOldNodesTest() throws Exception {
+    tester.getOrCreate("top").setComputedValue(CONCATENATE).addDependency("d1").addDependency("d2");
+    tester.set("d1", new StringValue("one"));
+    tester.set("d2", new StringValue("two"));
+    tester.eval(true, "top");
+
+    tester.set("d2", new StringValue("three"));
+    tester.invalidate();
+    tester.eval(true, "d2");
+
+    // The graph now contains the three above nodes (and ERROR_TRANSIENCE).
+    assertThat(tester.graph.getValues().keySet()).containsExactly(
+        skyKey("top"), skyKey("d1"), skyKey("d2"), ErrorTransienceValue.key());
+
+    String[] noKeys = {};
+    tester.graph.deleteDirty(2);
+    tester.eval(true, noKeys);
+
+    // The top node's value is dirty, but less than two generations old, so it wasn't deleted.
+    assertThat(tester.graph.getValues().keySet()).containsExactly(
+        skyKey("top"), skyKey("d1"), skyKey("d2"), ErrorTransienceValue.key());
+
+    tester.graph.deleteDirty(2);
+    tester.eval(true, noKeys);
+
+    // The top node's value was dirty, and was two generations old, so it was deleted.
+    assertThat(tester.graph.getValues().keySet()).containsExactly(
+        skyKey("d1"), skyKey("d2"), ErrorTransienceValue.key());
+  }
+
+  @Test
+  public void deleteNonexistentValues() throws Exception {
+    tester.getOrCreate("d1").setConstantValue(new StringValue("1"));
+    tester.delete("d1");
+    tester.delete("d2");
+    tester.eval(true, "d1");
+  }
+
+  @Test
+  public void signalValueEnqueued() throws Exception {
+    tester.getOrCreate("top1").setComputedValue(CONCATENATE)
+        .addDependency("d1").addDependency("d2");
+    tester.getOrCreate("top2").setComputedValue(CONCATENATE).addDependency("d3");
+    tester.getOrCreate("top3");
+    assertThat(tester.getEnqueuedValues()).isEmpty();
+
+    tester.set("d1", new StringValue("1"));
+    tester.set("d2", new StringValue("2"));
+    tester.set("d3", new StringValue("3"));
+    tester.eval(true, "top1");
+    assertThat(tester.getEnqueuedValues()).containsExactlyElementsIn(
+        Arrays.asList(MemoizingEvaluatorTester.toSkyKeys("top1", "d1", "d2")));
+
+    tester.eval(true, "top2");
+    assertThat(tester.getEnqueuedValues()).containsExactlyElementsIn(
+        Arrays.asList(MemoizingEvaluatorTester.toSkyKeys("top1", "d1", "d2", "top2", "d3")));
+  }
+
+  // NOTE: Some of these tests exercising errors/warnings run through a size-2 for loop in order
+  // to ensure that we are properly recording and replyaing these messages on null builds.
+  @Test
+  public void warningViaMultiplePaths() throws Exception {
+    tester.set("d1", new StringValue("d1")).setWarning("warn-d1");
+    tester.set("d2", new StringValue("d2")).setWarning("warn-d2");
+    tester.getOrCreate("top").setComputedValue(CONCATENATE).addDependency("d1").addDependency("d2");
+    for (int i = 0; i < 2; i++) {
+      initializeReporter();
+      tester.evalAndGet("top");
+      JunitTestUtils.assertContainsEvent(eventCollector, "warn-d1");
+      JunitTestUtils.assertContainsEvent(eventCollector, "warn-d2");
+      JunitTestUtils.assertEventCount(2, eventCollector);
+    }
+  }
+
+  @Test
+  public void warningBeforeErrorOnFailFastBuild() throws Exception {
+    tester.set("dep", new StringValue("dep")).setWarning("warn-dep");
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.getOrCreate(topKey).setHasError(true).addDependency("dep");
+    for (int i = 0; i < 2; i++) {
+      initializeReporter();
+      EvaluationResult<StringValue> result = tester.eval(false, "top");
+      assertTrue(result.hasError());
+      assertThat(result.getError(topKey).getRootCauses()).containsExactly(topKey);
+      assertEquals(topKey.toString(), result.getError(topKey).getException().getMessage());
+      assertTrue(result.getError(topKey).getException() instanceof SomeErrorException);
+      JunitTestUtils.assertContainsEvent(eventCollector, "warn-dep");
+      JunitTestUtils.assertEventCount(1, eventCollector);
+    }
+  }
+
+  @Test
+  public void warningAndErrorOnFailFastBuild() throws Exception {
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.set(topKey, new StringValue("top")).setWarning("warning msg").setHasError(true);
+    for (int i = 0; i < 2; i++) {
+      initializeReporter();
+      EvaluationResult<StringValue> result = tester.eval(false, "top");
+      assertTrue(result.hasError());
+      assertThat(result.getError(topKey).getRootCauses()).containsExactly(topKey);
+      assertEquals(topKey.toString(), result.getError(topKey).getException().getMessage());
+      assertTrue(result.getError(topKey).getException() instanceof SomeErrorException);
+      JunitTestUtils.assertContainsEvent(eventCollector, "warning msg");
+      JunitTestUtils.assertEventCount(1, eventCollector);
+    }
+  }
+
+  @Test
+  public void warningAndErrorOnFailFastBuildAfterKeepGoingBuild() throws Exception {
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.set(topKey, new StringValue("top")).setWarning("warning msg").setHasError(true);
+    for (int i = 0; i < 2; i++) {
+      initializeReporter();
+      EvaluationResult<StringValue> result = tester.eval(i == 0, "top");
+      assertTrue(result.hasError());
+      assertThat(result.getError(topKey).getRootCauses()).containsExactly(topKey);
+      assertEquals(topKey.toString(), result.getError(topKey).getException().getMessage());
+      assertTrue(result.getError(topKey).getException() instanceof SomeErrorException);
+      JunitTestUtils.assertContainsEvent(eventCollector, "warning msg");
+      JunitTestUtils.assertEventCount(1, eventCollector);
+    }
+  }
+
+  @Test
+  public void twoTLTsOnOneWarningValue() throws Exception {
+    tester.set("t1", new StringValue("t1")).addDependency("dep");
+    tester.set("t2", new StringValue("t2")).addDependency("dep");
+    tester.set("dep", new StringValue("dep")).setWarning("look both ways before crossing");
+    for (int i = 0; i < 2; i++) {
+      // Make sure we see the warning exactly once.
+      initializeReporter();
+      tester.eval(/*keepGoing=*/false, "t1", "t2");
+      JunitTestUtils.assertContainsEvent(eventCollector, "look both ways before crossing");
+      JunitTestUtils.assertEventCount(1, eventCollector);
+    }
+  }
+
+  @Test
+  public void errorValueDepOnWarningValue() throws Exception {
+    tester.getOrCreate("error-value").setHasError(true).addDependency("warning-value");
+    tester.set("warning-value", new StringValue("warning-value"))
+        .setWarning("don't chew with your mouth open");
+
+    for (int i = 0; i < 2; i++) {
+      initializeReporter();
+      tester.evalAndGetError("error-value");
+      JunitTestUtils.assertContainsEvent(eventCollector, "don't chew with your mouth open");
+      JunitTestUtils.assertEventCount(1, eventCollector);
+    }
+
+    initializeReporter();
+    tester.evalAndGet("warning-value");
+    JunitTestUtils.assertContainsEvent(eventCollector, "don't chew with your mouth open");
+    JunitTestUtils.assertEventCount(1, eventCollector);
+  }
+
+  @Test
+  public void progressMessageOnlyPrintedTheFirstTime() throws Exception {
+    // The framework keeps track of warning and error messages, but not progress messages.
+    // So here we see both the progress and warning on the first build, but only the warning
+    // on the subsequent null build.
+    tester.set("x", new StringValue("y")).setWarning("fizzlepop")
+        .setProgress("just letting you know");
+
+    StringValue value = (StringValue) tester.evalAndGet("x");
+    assertEquals("y", value.getValue());
+    JunitTestUtils.assertContainsEvent(eventCollector, "fizzlepop");
+    JunitTestUtils.assertContainsEvent(eventCollector, "just letting you know");
+    JunitTestUtils.assertEventCount(2, eventCollector);
+
+    // On the rebuild, we only replay warning messages.
+    initializeReporter();
+    value = (StringValue) tester.evalAndGet("x");
+    assertEquals("y", value.getValue());
+    JunitTestUtils.assertContainsEvent(eventCollector, "fizzlepop");
+    JunitTestUtils.assertEventCount(1, eventCollector);
+  }
+
+  @Test
+  public void invalidationWithChangeAndThenNothingChanged() throws Exception {
+    tester.getOrCreate("a")
+        .addDependency("b")
+        .setComputedValue(COPY);
+    tester.set("b", new StringValue("y"));
+    StringValue original = (StringValue) tester.evalAndGet("a");
+    assertEquals("y", original.getValue());
+    tester.set("b", new StringValue("z"));
+    tester.invalidate();
+    StringValue old = (StringValue) tester.evalAndGet("a");
+    assertEquals("z", old.getValue());
+    tester.invalidate();
+    StringValue current = (StringValue) tester.evalAndGet("a");
+    assertSame(old, current);
+  }
+
+  @Test
+  public void transientErrorValueInvalidation() throws Exception {
+    // Verify that invalidating errors causes all transient error values to be rerun.
+    tester.getOrCreate("error-value").setHasTransientError(true).setProgress(
+        "just letting you know");
+
+    tester.evalAndGetError("error-value");
+    JunitTestUtils.assertContainsEvent(eventCollector, "just letting you know");
+    JunitTestUtils.assertEventCount(1, eventCollector);
+
+    // Change the progress message.
+    tester.getOrCreate("error-value").setHasTransientError(true).setProgress(
+        "letting you know more");
+
+    // Without invalidating errors, we shouldn't show the new progress message.
+    for (int i = 0; i < 2; i++) {
+      initializeReporter();
+      tester.evalAndGetError("error-value");
+      JunitTestUtils.assertNoEvents(eventCollector);
+    }
+
+    // When invalidating errors, we should show the new progress message.
+    initializeReporter();
+    tester.invalidateTransientErrors();
+    tester.evalAndGetError("error-value");
+    JunitTestUtils.assertContainsEvent(eventCollector, "letting you know more");
+    JunitTestUtils.assertEventCount(1, eventCollector);
+  }
+
+  @Test
+  public void simpleDependency() throws Exception {
+    tester.getOrCreate("ab")
+        .addDependency("a")
+        .setComputedValue(COPY);
+    tester.set("a", new StringValue("me"));
+    StringValue value = (StringValue) tester.evalAndGet("ab");
+    assertEquals("me", value.getValue());
+  }
+
+  @Test
+  public void incrementalSimpleDependency() throws Exception {
+    tester.getOrCreate("ab")
+        .addDependency("a")
+        .setComputedValue(COPY);
+    tester.set("a", new StringValue("me"));
+    tester.evalAndGet("ab");
+
+    tester.set("a", new StringValue("other"));
+    tester.invalidate();
+    StringValue value = (StringValue) tester.evalAndGet("ab");
+    assertEquals("other", value.getValue());
+  }
+
+  @Test
+  public void diamondDependency() throws Exception {
+    setupDiamondDependency();
+    tester.set("d", new StringValue("me"));
+    StringValue value = (StringValue) tester.evalAndGet("a");
+    assertEquals("meme", value.getValue());
+  }
+
+  @Test
+  public void incrementalDiamondDependency() throws Exception {
+    setupDiamondDependency();
+    tester.set("d", new StringValue("me"));
+    tester.evalAndGet("a");
+
+    tester.set("d", new StringValue("other"));
+    tester.invalidate();
+    StringValue value = (StringValue) tester.evalAndGet("a");
+    assertEquals("otherother", value.getValue());
+  }
+
+  private void setupDiamondDependency() {
+    tester.getOrCreate("a")
+        .addDependency("b")
+        .addDependency("c")
+        .setComputedValue(CONCATENATE);
+    tester.getOrCreate("b")
+        .addDependency("d")
+        .setComputedValue(COPY);
+    tester.getOrCreate("c")
+        .addDependency("d")
+        .setComputedValue(COPY);
+  }
+
+  // Regression test: ParallelEvaluator notifies ValueProgressReceiver of already-built top-level
+  // values in error: we built "top" and "mid" as top-level targets; "mid" contains an error. We
+  // make sure "mid" is built as a dependency of "top" before enqueuing mid as a top-level target
+  // (by using a latch), so that the top-level enqueuing finds that mid has already been built. The
+  // progress receiver should not be notified of any value having been evaluated.
+  @Test
+  public void alreadyAnalyzedBadTarget() throws Exception {
+    final SkyKey mid = GraphTester.toSkyKey("mid");
+    final CountDownLatch valueSet = new CountDownLatch(1);
+    final TrackingAwaiter trackingAwaiter = new TrackingAwaiter();
+    setGraphForTesting(new NotifyingInMemoryGraph(new Listener() {
+      @Override
+      public void accept(SkyKey key, EventType type, Order order, Object context) {
+        if (!key.equals(mid)) {
+          return;
+        }
+        switch (type) {
+          case ADD_REVERSE_DEP:
+            if (context == null) {
+              // Context is null when we are enqueuing this value as a top-level job.
+              trackingAwaiter.awaitLatchAndTrackExceptions(valueSet, "value not set");
+            }
+            break;
+          case SET_VALUE:
+            valueSet.countDown();
+            break;
+          default:
+            break;
+        }
+      }
+    }));
+    SkyKey top = GraphTester.skyKey("top");
+    tester.getOrCreate(top).addDependency(mid).setComputedValue(CONCATENATE);
+    tester.getOrCreate(mid).setHasError(true);
+    tester.eval(/*keepGoing=*/false, top, mid);
+    assertEquals(0L, valueSet.getCount());
+    trackingAwaiter.assertNoErrors();
+    assertThat(tester.invalidationReceiver.evaluated).isEmpty();
+  }
+
+  @Test
+  public void receiverNotToldOfVerifiedValueDependingOnCycle() throws Exception {
+    SkyKey leaf = GraphTester.toSkyKey("leaf");
+    SkyKey cycle = GraphTester.toSkyKey("cycle");
+    SkyKey top = GraphTester.toSkyKey("top");
+    tester.set(leaf, new StringValue("leaf"));
+    tester.getOrCreate(cycle).addDependency(cycle);
+    tester.getOrCreate(top).addDependency(leaf).addDependency(cycle);
+    tester.eval(/*keepGoing=*/true, top);
+    assertThat(tester.invalidationReceiver.evaluated).containsExactly(leaf).inOrder();
+    tester.invalidationReceiver.clear();
+    tester.getOrCreate(leaf, /*markAsModified=*/true);
+    tester.invalidate();
+    tester.eval(/*keepGoing=*/true, top);
+    assertThat(tester.invalidationReceiver.evaluated).containsExactly(leaf).inOrder();
+  }
+
+  @Test
+  public void incrementalAddedDependency() throws Exception {
+    tester.getOrCreate("a")
+        .addDependency("b")
+        .setComputedValue(CONCATENATE);
+    tester.set("b", new StringValue("first"));
+    tester.set("c", new StringValue("second"));
+    tester.evalAndGet("a");
+
+    tester.getOrCreate("a").addDependency("c");
+    tester.set("b", new StringValue("now"));
+    tester.invalidate();
+    StringValue value = (StringValue) tester.evalAndGet("a");
+    assertEquals("nowsecond", value.getValue());
+  }
+
+  @Test
+  public void manyValuesDependOnSingleValue() throws Exception {
+    initializeTester();
+    String[] values = new String[TEST_NODE_COUNT];
+    for (int i = 0; i < values.length; i++) {
+      values[i] = Integer.toString(i);
+      tester.getOrCreate(values[i])
+          .addDependency("leaf")
+          .setComputedValue(COPY);
+    }
+    tester.set("leaf", new StringValue("leaf"));
+
+    EvaluationResult<StringValue> result = tester.eval(/*keep_going=*/false, values);
+    for (int i = 0; i < values.length; i++) {
+      SkyValue actual = result.get(new SkyKey(GraphTester.NODE_TYPE, values[i]));
+      assertEquals(new StringValue("leaf"), actual);
+    }
+
+    for (int j = 0; j < TESTED_NODES; j++) {
+      tester.set("leaf", new StringValue("other" + j));
+      tester.invalidate();
+      result = tester.eval(/*keep_going=*/false, values);
+      for (int i = 0; i < values.length; i++) {
+        SkyValue actual = result.get(new SkyKey(GraphTester.NODE_TYPE, values[i]));
+        assertEquals("Run " + j + ", value " + i, new StringValue("other" + j), actual);
+      }
+    }
+  }
+
+  @Test
+  public void singleValueDependsOnManyValues() throws Exception {
+    initializeTester();
+    String[] values = new String[TEST_NODE_COUNT];
+    StringBuilder expected = new StringBuilder();
+    for (int i = 0; i < values.length; i++) {
+      values[i] = Integer.toString(i);
+      tester.set(values[i], new StringValue(values[i]));
+      expected.append(values[i]);
+    }
+    SkyKey rootKey = new SkyKey(GraphTester.NODE_TYPE, "root");
+    TestFunction value = tester.getOrCreate(rootKey)
+        .setComputedValue(CONCATENATE);
+    for (int i = 0; i < values.length; i++) {
+      value.addDependency(values[i]);
+    }
+
+    EvaluationResult<StringValue> result = tester.eval(/*keep_going=*/false, rootKey);
+    assertEquals(new StringValue(expected.toString()), result.get(rootKey));
+
+    for (int j = 0; j < 10; j++) {
+      expected.setLength(0);
+      for (int i = 0; i < values.length; i++) {
+        String s = "other" + i + " " + j;
+        tester.set(values[i], new StringValue(s));
+        expected.append(s);
+      }
+      tester.invalidate();
+
+      result = tester.eval(/*keep_going=*/false, rootKey);
+      assertEquals(new StringValue(expected.toString()), result.get(rootKey));
+    }
+  }
+
+  @Test
+  public void twoRailLeftRightDependencies() throws Exception {
+    initializeTester();
+    String[] leftValues = new String[TEST_NODE_COUNT];
+    String[] rightValues = new String[TEST_NODE_COUNT];
+    for (int i = 0; i < leftValues.length; i++) {
+      leftValues[i] = "left-" + i;
+      rightValues[i] = "right-" + i;
+      if (i == 0) {
+        tester.getOrCreate(leftValues[i])
+              .addDependency("leaf")
+              .setComputedValue(COPY);
+        tester.getOrCreate(rightValues[i])
+              .addDependency("leaf")
+              .setComputedValue(COPY);
+      } else {
+        tester.getOrCreate(leftValues[i])
+              .addDependency(leftValues[i - 1])
+              .addDependency(rightValues[i - 1])
+              .setComputedValue(new PassThroughSelected(toSkyKey(leftValues[i - 1])));
+        tester.getOrCreate(rightValues[i])
+              .addDependency(leftValues[i - 1])
+              .addDependency(rightValues[i - 1])
+              .setComputedValue(new PassThroughSelected(toSkyKey(rightValues[i - 1])));
+      }
+    }
+    tester.set("leaf", new StringValue("leaf"));
+
+    String lastLeft = "left-" + (TEST_NODE_COUNT - 1);
+    String lastRight = "right-" + (TEST_NODE_COUNT - 1);
+
+    EvaluationResult<StringValue> result = tester.eval(/*keep_going=*/false, lastLeft, lastRight);
+    assertEquals(new StringValue("leaf"), result.get(toSkyKey(lastLeft)));
+    assertEquals(new StringValue("leaf"), result.get(toSkyKey(lastRight)));
+
+    for (int j = 0; j < TESTED_NODES; j++) {
+      String value = "other" + j;
+      tester.set("leaf", new StringValue(value));
+      tester.invalidate();
+      result = tester.eval(/*keep_going=*/false, lastLeft, lastRight);
+      assertEquals(new StringValue(value), result.get(toSkyKey(lastLeft)));
+      assertEquals(new StringValue(value), result.get(toSkyKey(lastRight)));
+    }
+  }
+
+  @Test
+  public void noKeepGoingAfterKeepGoingCycle() throws Exception {
+    initializeTester();
+    SkyKey aKey = GraphTester.toSkyKey("a");
+    SkyKey bKey = GraphTester.toSkyKey("b");
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    SkyKey midKey = GraphTester.toSkyKey("mid");
+    SkyKey goodKey = GraphTester.toSkyKey("good");
+    StringValue goodValue = new StringValue("good");
+    tester.set(goodKey, goodValue);
+    tester.getOrCreate(topKey).addDependency(midKey);
+    tester.getOrCreate(midKey).addDependency(aKey);
+    tester.getOrCreate(aKey).addDependency(bKey);
+    tester.getOrCreate(bKey).addDependency(aKey);
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/true, topKey, goodKey);
+    assertEquals(goodValue, result.get(goodKey));
+    assertEquals(null, result.get(topKey));
+    ErrorInfo errorInfo = result.getError(topKey);
+    CycleInfo cycleInfo = Iterables.getOnlyElement(errorInfo.getCycleInfo());
+    assertThat(cycleInfo.getCycle()).containsExactly(aKey, bKey).inOrder();
+    assertThat(cycleInfo.getPathToCycle()).containsExactly(topKey, midKey).inOrder();
+
+    tester.invalidate();
+    result = tester.eval(/*keepGoing=*/false, topKey, goodKey);
+    assertEquals(null, result.get(topKey));
+    errorInfo = result.getError(topKey);
+    cycleInfo = Iterables.getOnlyElement(errorInfo.getCycleInfo());
+    assertThat(cycleInfo.getCycle()).containsExactly(aKey, bKey).inOrder();
+    assertThat(cycleInfo.getPathToCycle()).containsExactly(topKey, midKey).inOrder();
+  }
+
+  @Test
+  public void changeCycle() throws Exception {
+    initializeTester();
+    SkyKey aKey = GraphTester.toSkyKey("a");
+    SkyKey bKey = GraphTester.toSkyKey("b");
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    SkyKey midKey = GraphTester.toSkyKey("mid");
+    tester.getOrCreate(topKey).addDependency(midKey).setComputedValue(COPY);
+    tester.getOrCreate(midKey).addDependency(aKey).setComputedValue(COPY);
+    tester.getOrCreate(aKey).addDependency(bKey).setComputedValue(COPY);
+    tester.getOrCreate(bKey).addDependency(aKey);
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, topKey);
+    assertEquals(null, result.get(topKey));
+    ErrorInfo errorInfo = result.getError(topKey);
+    CycleInfo cycleInfo = Iterables.getOnlyElement(errorInfo.getCycleInfo());
+    assertThat(cycleInfo.getCycle()).containsExactly(aKey, bKey).inOrder();
+    assertThat(cycleInfo.getPathToCycle()).containsExactly(topKey, midKey).inOrder();
+
+    tester.getOrCreate(bKey).removeDependency(aKey);
+    tester.set(bKey, new StringValue("bValue"));
+    tester.invalidate();
+    result = tester.eval(/*keepGoing=*/false, topKey);
+    assertEquals(new StringValue("bValue"), result.get(topKey));
+    assertEquals(null, result.getError(topKey));
+  }
+
+  /** Regression test: "crash in cycle checker with dirty values". */
+  @Test
+  public void cycleAndSelfEdgeWithDirtyValue() throws Exception {
+    initializeTester();
+    SkyKey cycleKey1 = GraphTester.toSkyKey("cycleKey1");
+    SkyKey cycleKey2 = GraphTester.toSkyKey("cycleKey2");
+    tester.getOrCreate(cycleKey1).addDependency(cycleKey2).addDependency(cycleKey1)
+    .setComputedValue(CONCATENATE);
+    tester.getOrCreate(cycleKey2).addDependency(cycleKey1).setComputedValue(COPY);
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/true, cycleKey1);
+    assertEquals(null, result.get(cycleKey1));
+    ErrorInfo errorInfo = result.getError(cycleKey1);
+    CycleInfo cycleInfo = Iterables.getOnlyElement(errorInfo.getCycleInfo());
+    assertThat(cycleInfo.getCycle()).containsExactly(cycleKey1).inOrder();
+    assertThat(cycleInfo.getPathToCycle()).isEmpty();
+    tester.getOrCreate(cycleKey1, /*markAsModified=*/true);
+    tester.invalidate();
+    result = tester.eval(/*keepGoing=*/true, cycleKey1, cycleKey2);
+    assertEquals(null, result.get(cycleKey1));
+    errorInfo = result.getError(cycleKey1);
+    cycleInfo = Iterables.getOnlyElement(errorInfo.getCycleInfo());
+    assertThat(cycleInfo.getCycle()).containsExactly(cycleKey1).inOrder();
+    assertThat(cycleInfo.getPathToCycle()).isEmpty();
+    cycleInfo =
+        Iterables.getOnlyElement(tester.graph.getExistingErrorForTesting(cycleKey2).getCycleInfo());
+    assertThat(cycleInfo.getCycle()).containsExactly(cycleKey1).inOrder();
+    assertThat(cycleInfo.getPathToCycle()).containsExactly(cycleKey2).inOrder();
+  }
+
+  /** Regression test: "crash in cycle checker with dirty values". */
+  @Test
+  public void cycleWithDirtyValue() throws Exception {
+    initializeTester();
+    SkyKey cycleKey1 = GraphTester.toSkyKey("cycleKey1");
+    SkyKey cycleKey2 = GraphTester.toSkyKey("cycleKey2");
+    tester.getOrCreate(cycleKey1).addDependency(cycleKey2).setComputedValue(COPY);
+    tester.getOrCreate(cycleKey2).addDependency(cycleKey1).setComputedValue(COPY);
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/true, cycleKey1);
+    assertEquals(null, result.get(cycleKey1));
+    ErrorInfo errorInfo = result.getError(cycleKey1);
+    CycleInfo cycleInfo = Iterables.getOnlyElement(errorInfo.getCycleInfo());
+    assertThat(cycleInfo.getCycle()).containsExactly(cycleKey1, cycleKey2).inOrder();
+    assertThat(cycleInfo.getPathToCycle()).isEmpty();
+    tester.getOrCreate(cycleKey1, /*markAsModified=*/true);
+    tester.invalidate();
+    result = tester.eval(/*keepGoing=*/true, cycleKey1);
+    assertEquals(null, result.get(cycleKey1));
+    errorInfo = result.getError(cycleKey1);
+    cycleInfo = Iterables.getOnlyElement(errorInfo.getCycleInfo());
+    assertThat(cycleInfo.getCycle()).containsExactly(cycleKey1, cycleKey2).inOrder();
+    assertThat(cycleInfo.getPathToCycle()).isEmpty();
+  }
+
+  /**
+   * Regression test: IllegalStateException in BuildingState.isReady(). The ParallelEvaluator used
+   * to assume during cycle-checking that all values had been built as fully as possible -- that
+   * evaluation had not been interrupted. However, we also do cycle-checking in nokeep-going mode
+   * when a value throws an error (possibly prematurely shutting down evaluation) but that error
+   * then bubbles up into a cycle.
+   *
+   * <p>We want to achieve the following state: we are checking for a cycle; the value we examine
+   * has not yet finished checking its children to see if they are dirty; but all children checked
+   * so far have been unchanged. This value is "otherTop". We first build otherTop, then mark its
+   * first child changed (without actually changing it), and then do a second build. On the second
+   * build, we also build "top", which requests a cycle that depends on an error. We wait to signal
+   * otherTop that its first child is done until the error throws and shuts down evaluation. The
+   * error then bubbles up to the cycle, and so the bubbling is aborted. Finally, cycle checking
+   * happens, and otherTop is examined, as desired.
+   */
+  @Test
+  public void cycleAndErrorAndReady() throws Exception {
+    // This value will not have finished building on the second build when the error is thrown.
+    final SkyKey otherTop = GraphTester.toSkyKey("otherTop");
+    final SkyKey errorKey = GraphTester.toSkyKey("error");
+    // Is the graph state all set up and ready for the error to be thrown?
+    final CountDownLatch valuesReady = new CountDownLatch(3);
+    // Is evaluation being shut down? This is counted down by the exceptionMarker's builder, after
+    // it has waited for the threadpool's exception latch to be released.
+    final CountDownLatch errorThrown = new CountDownLatch(1);
+    // We don't do anything on the first build.
+    final AtomicBoolean secondBuild = new AtomicBoolean(false);
+    final TrackingAwaiter trackingAwaiter = new TrackingAwaiter();
+    setGraphForTesting(new DeterministicInMemoryGraph(new Listener() {
+      @Override
+      public void accept(SkyKey key, EventType type, Order order, Object context) {
+        if (!secondBuild.get()) {
+          return;
+        }
+        if (key.equals(errorKey) && type == EventType.SET_VALUE) {
+          // If the error is about to be thrown, make sure all listeners are ready.
+          trackingAwaiter.awaitLatchAndTrackExceptions(valuesReady, "waiting values not ready");
+          return;
+        }
+        if (key.equals(otherTop) && type == EventType.SIGNAL) {
+          // otherTop is being signaled that dep1 is done. Tell the error value that it is ready,
+          // then wait until the error is thrown, so that otherTop's builder is not re-entered.
+          valuesReady.countDown();
+          trackingAwaiter.awaitLatchAndTrackExceptions(errorThrown, "error not thrown");
+          return;
+        }
+      }
+    }));
+    final SkyKey dep1 = GraphTester.toSkyKey("dep1");
+    tester.set(dep1, new StringValue("dep1"));
+    final SkyKey dep2 = GraphTester.toSkyKey("dep2");
+    tester.set(dep2, new StringValue("dep2"));
+    // otherTop should request the deps one at a time, so that it can be in the CHECK_DEPENDENCIES
+    // state even after one dep is re-evaluated.
+    tester.getOrCreate(otherTop).setBuilder(new NoExtractorFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) {
+        env.getValue(dep1);
+        if (env.valuesMissing()) {
+          return null;
+        }
+        env.getValue(dep2);
+        return env.valuesMissing() ? null : new StringValue("otherTop");
+      }
+    });
+    // Prime the graph with otherTop, so we can dirty it next build.
+    assertEquals(new StringValue("otherTop"), tester.evalAndGet(/*keepGoing=*/false, otherTop));
+    // Mark dep1 changed, so otherTop will be dirty and request re-evaluation of dep1.
+    tester.getOrCreate(dep1, /*markAsModified=*/true);
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    // Note that since DeterministicInMemoryGraph alphabetizes reverse deps, it is important that
+    // "cycle2" comes before "top".
+    final SkyKey cycle1Key = GraphTester.toSkyKey("cycle1");
+    final SkyKey cycle2Key = GraphTester.toSkyKey("cycle2");
+    tester.getOrCreate(topKey).addDependency(cycle1Key).setComputedValue(CONCATENATE);
+    tester.getOrCreate(cycle1Key).addDependency(errorKey).addDependency(cycle2Key)
+        .setComputedValue(CONCATENATE);
+    tester.getOrCreate(errorKey).setHasError(true);
+    // Make sure cycle2Key has declared its dependence on cycle1Key before error throws.
+    tester.getOrCreate(cycle2Key).setBuilder(new ChainedFunction(/*notifyStart=*/valuesReady,
+        null, null, false, new StringValue("never returned"), ImmutableList.<SkyKey>of(cycle1Key)));
+    // Value that waits until an exception is thrown to finish building. We use it just to be
+    // informed when the threadpool is shutting down.
+    final SkyKey exceptionMarker = GraphTester.toSkyKey("exceptionMarker");
+    tester.getOrCreate(exceptionMarker).setBuilder(new ChainedFunction(
+        /*notifyStart=*/valuesReady, /*waitToFinish=*/new CountDownLatch(0),
+        /*notifyFinish=*/errorThrown,
+        /*waitForException=*/true, new StringValue("exception marker"),
+        ImmutableList.<SkyKey>of()));
+    tester.invalidate();
+    secondBuild.set(true);
+    // otherTop must be first, since we check top-level values for cycles in the order in which
+    // they appear here.
+    EvaluationResult<StringValue> result =
+        tester.eval(/*keepGoing=*/false, otherTop, topKey, exceptionMarker);
+    trackingAwaiter.assertNoErrors();
+    assertThat(result.errorMap().keySet()).containsExactly(topKey);
+    Iterable<CycleInfo> cycleInfos = result.getError(topKey).getCycleInfo();
+    assertWithMessage(result.toString()).that(cycleInfos).isNotEmpty();
+    CycleInfo cycleInfo = Iterables.getOnlyElement(cycleInfos);
+    assertThat(cycleInfo.getPathToCycle()).containsExactly(topKey);
+    assertThat(cycleInfo.getCycle()).containsExactly(cycle1Key, cycle2Key);
+  }
+
+  @Test
+  public void limitEvaluatorThreads() throws Exception {
+    initializeTester();
+
+    int numKeys = 10;
+    final Object lock = new Object();
+    final AtomicInteger inProgressCount = new AtomicInteger();
+    final int[] maxValue = {0};
+
+    SkyKey topLevel = GraphTester.toSkyKey("toplevel");
+    TestFunction topLevelBuilder = tester.getOrCreate(topLevel);
+    for (int i = 0; i < numKeys; i++) {
+      topLevelBuilder.addDependency("subKey" + i);
+      tester.getOrCreate("subKey" + i).setComputedValue(new ValueComputer() {
+        @Override
+        public SkyValue compute(Map<SkyKey, SkyValue> deps, SkyFunction.Environment env) {
+          int val = inProgressCount.incrementAndGet();
+          synchronized (lock) {
+            if (val > maxValue[0]) {
+              maxValue[0] = val;
+            }
+          }
+          Uninterruptibles.sleepUninterruptibly(5, TimeUnit.SECONDS);
+
+          inProgressCount.decrementAndGet();
+          return new StringValue("abc");
+        }
+      });
+    }
+    topLevelBuilder.setConstantValue(new StringValue("xyz"));
+
+    EvaluationResult<StringValue> result = tester.eval(
+        /*keepGoing=*/true, /*numThreads=*/5, topLevel);
+    assertFalse(result.hasError());
+    assertEquals(5, maxValue[0]);
+  }
+
+  /**
+   * Regression test: error on clearMaybeDirtyValue. We do an evaluation of topKey, which registers
+   * dependencies on midKey and errorKey. midKey enqueues slowKey, and waits. errorKey throws an
+   * error, which bubbles up to topKey. If topKey does not unregister its dependence on midKey, it
+   * will have a dangling reference to midKey after unfinished values are cleaned from the graph.
+   * Note that slowKey will wait until errorKey has thrown and the threadpool has caught the
+   * exception before returning, so the Evaluator will already have stopped enqueuing new jobs, so
+   * midKey is not evaluated.
+   */
+  @Test
+  public void incompleteDirectDepsAreClearedBeforeInvalidation() throws Exception {
+    initializeTester();
+    CountDownLatch slowStart = new CountDownLatch(1);
+    CountDownLatch errorFinish = new CountDownLatch(1);
+    SkyKey errorKey = GraphTester.toSkyKey("error");
+    tester.getOrCreate(errorKey).setBuilder(
+        new ChainedFunction(/*notifyStart=*/null, /*waitToFinish=*/slowStart,
+            /*notifyFinish=*/errorFinish, /*waitForException=*/false, /*value=*/null,
+            /*deps=*/ImmutableList.<SkyKey>of()));
+    SkyKey slowKey = GraphTester.toSkyKey("slow");
+    tester.getOrCreate(slowKey).setBuilder(
+        new ChainedFunction(/*notifyStart=*/slowStart, /*waitToFinish=*/errorFinish,
+            /*notifyFinish=*/null, /*waitForException=*/true, new StringValue("slow"),
+            /*deps=*/ImmutableList.<SkyKey>of()));
+    SkyKey midKey = GraphTester.toSkyKey("mid");
+    tester.getOrCreate(midKey).addDependency(slowKey).setComputedValue(COPY);
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.getOrCreate(topKey).addDependency(midKey).addDependency(errorKey)
+        .setComputedValue(CONCATENATE);
+    // slowKey starts -> errorKey finishes, written to graph -> slowKey finishes & (Visitor aborts)
+    // -> topKey builds.
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, topKey);
+    assertThat(result.getError().getRootCauses()).containsExactly(errorKey);
+    // Make sure midKey didn't finish building.
+    assertEquals(null, tester.graph.getExistingValueForTesting(midKey));
+    // Give slowKey a nice ordinary builder.
+    tester.getOrCreate(slowKey, /*markAsModified=*/false).setBuilder(null)
+        .setConstantValue(new StringValue("slow"));
+    // Put midKey into the graph. It won't have a reverse dependence on topKey.
+    tester.evalAndGet(/*keepGoing=*/false, midKey);
+    tester.differencer.invalidate(ImmutableList.of(errorKey));
+    // topKey should not access midKey as if it were already registered as a dependency.
+    tester.eval(/*keepGoing=*/false, topKey);
+  }
+
+  /**
+   * Regression test: error on clearMaybeDirtyValue. Same as the previous test, but the second
+   * evaluation is keepGoing, which should cause an access of the children of topKey.
+   */
+  @Test
+  public void incompleteDirectDepsAreClearedBeforeKeepGoing() throws Exception {
+    initializeTester();
+    CountDownLatch slowStart = new CountDownLatch(1);
+    CountDownLatch errorFinish = new CountDownLatch(1);
+    SkyKey errorKey = GraphTester.toSkyKey("error");
+    tester.getOrCreate(errorKey).setBuilder(
+        new ChainedFunction(/*notifyStart=*/null, /*waitToFinish=*/slowStart,
+            /*notifyFinish=*/errorFinish, /*waitForException=*/false, /*value=*/null,
+            /*deps=*/ImmutableList.<SkyKey>of()));
+    SkyKey slowKey = GraphTester.toSkyKey("slow");
+    tester.getOrCreate(slowKey).setBuilder(
+        new ChainedFunction(/*notifyStart=*/slowStart, /*waitToFinish=*/errorFinish,
+            /*notifyFinish=*/null, /*waitForException=*/true, new StringValue("slow"),
+            /*deps=*/ImmutableList.<SkyKey>of()));
+    SkyKey midKey = GraphTester.toSkyKey("mid");
+    tester.getOrCreate(midKey).addDependency(slowKey).setComputedValue(COPY);
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.getOrCreate(topKey).addDependency(midKey).addDependency(errorKey)
+        .setComputedValue(CONCATENATE);
+    // slowKey starts -> errorKey finishes, written to graph -> slowKey finishes & (Visitor aborts)
+    // -> topKey builds.
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, topKey);
+    assertThat(result.getError().getRootCauses()).containsExactly(errorKey);
+    // Make sure midKey didn't finish building.
+    assertEquals(null, tester.graph.getExistingValueForTesting(midKey));
+    // Give slowKey a nice ordinary builder.
+    tester.getOrCreate(slowKey, /*markAsModified=*/false).setBuilder(null)
+        .setConstantValue(new StringValue("slow"));
+    // Put midKey into the graph. It won't have a reverse dependence on topKey.
+    tester.evalAndGet(/*keepGoing=*/false, midKey);
+    // topKey should not access midKey as if it were already registered as a dependency.
+    // We don't invalidate errors, but because topKey wasn't actually written to the graph last
+    // build, it should be rebuilt here.
+    tester.eval(/*keepGoing=*/true, topKey);
+  }
+
+  /**
+   * Regression test: tests that pass before other build actions fail yield crash in non -k builds.
+   */
+  @Test
+  public void passThenFailToBuild() throws Exception {
+    CountDownLatch blocker = new CountDownLatch(1);
+    SkyKey successKey = GraphTester.toSkyKey("success");
+    tester.getOrCreate(successKey).setBuilder(
+        new ChainedFunction(/*notifyStart=*/null, /*waitToFinish=*/null,
+            /*notifyFinish=*/blocker, /*waitForException=*/false, new StringValue("yippee"),
+            /*deps=*/ImmutableList.<SkyKey>of()));
+    SkyKey slowFailKey = GraphTester.toSkyKey("slow_then_fail");
+    tester.getOrCreate(slowFailKey).setBuilder(
+        new ChainedFunction(/*notifyStart=*/null, /*waitToFinish=*/blocker,
+            /*notifyFinish=*/null, /*waitForException=*/false, /*value=*/null,
+            /*deps=*/ImmutableList.<SkyKey>of()));
+
+    EvaluationResult<StringValue> result = tester.eval(
+        /*keepGoing=*/false, successKey, slowFailKey);
+    assertThat(result.getError().getRootCauses()).containsExactly(slowFailKey);
+    assertThat(result.values()).containsExactly(new StringValue("yippee"));
+  }
+
+  @Test
+  public void passThenFailToBuildAlternateOrder() throws Exception {
+    CountDownLatch blocker = new CountDownLatch(1);
+    SkyKey successKey = GraphTester.toSkyKey("success");
+    tester.getOrCreate(successKey).setBuilder(
+        new ChainedFunction(/*notifyStart=*/null, /*waitToFinish=*/null,
+            /*notifyFinish=*/blocker, /*waitForException=*/false, new StringValue("yippee"),
+            /*deps=*/ImmutableList.<SkyKey>of()));
+    SkyKey slowFailKey = GraphTester.toSkyKey("slow_then_fail");
+    tester.getOrCreate(slowFailKey).setBuilder(
+        new ChainedFunction(/*notifyStart=*/null, /*waitToFinish=*/blocker,
+            /*notifyFinish=*/null, /*waitForException=*/false, /*value=*/null,
+            /*deps=*/ImmutableList.<SkyKey>of()));
+
+    EvaluationResult<StringValue> result = tester.eval(
+        /*keepGoing=*/false, slowFailKey, successKey);
+    assertThat(result.getError().getRootCauses()).containsExactly(slowFailKey);
+    assertThat(result.values()).containsExactly(new StringValue("yippee"));
+  }
+
+  @Test
+  public void incompleteDirectDepsForDirtyValue() throws Exception {
+    initializeTester();
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.set(topKey, new StringValue("initial"));
+    // Put topKey into graph so it will be dirtied on next run.
+    assertEquals(new StringValue("initial"), tester.evalAndGet(/*keepGoing=*/false, topKey));
+    CountDownLatch slowStart = new CountDownLatch(1);
+    CountDownLatch errorFinish = new CountDownLatch(1);
+    SkyKey errorKey = GraphTester.toSkyKey("error");
+    tester.getOrCreate(errorKey).setBuilder(
+        new ChainedFunction(/*notifyStart=*/null, /*waitToFinish=*/slowStart,
+            /*notifyFinish=*/errorFinish,
+            /*waitForException=*/false, /*value=*/null, /*deps=*/ImmutableList.<SkyKey>of()));
+    SkyKey slowKey = GraphTester.toSkyKey("slow");
+    tester.getOrCreate(slowKey).setBuilder(
+        new ChainedFunction(/*notifyStart=*/slowStart, /*waitToFinish=*/errorFinish,
+            /*notifyFinish=*/null, /*waitForException=*/true,
+            new StringValue("slow"), /*deps=*/ImmutableList.<SkyKey>of()));
+    SkyKey midKey = GraphTester.toSkyKey("mid");
+    tester.getOrCreate(midKey).addDependency(slowKey).setComputedValue(COPY);
+    tester.set(topKey, null);
+    tester.getOrCreate(topKey).addDependency(midKey).addDependency(errorKey)
+        .setComputedValue(CONCATENATE);
+    tester.invalidate();
+    // slowKey starts -> errorKey finishes, written to graph -> slowKey finishes & (Visitor aborts)
+    // -> topKey builds.
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, topKey);
+    assertThat(result.getError().getRootCauses()).containsExactly(errorKey);
+    // Make sure midKey didn't finish building.
+    assertEquals(null, tester.graph.getExistingValueForTesting(midKey));
+    // Give slowKey a nice ordinary builder.
+    tester.getOrCreate(slowKey, /*markAsModified=*/false).setBuilder(null)
+        .setConstantValue(new StringValue("slow"));
+    // Put midKey into the graph. It won't have a reverse dependence on topKey.
+    tester.evalAndGet(/*keepGoing=*/false, midKey);
+    // topKey should not access midKey as if it were already registered as a dependency.
+    // We don't invalidate errors, but since topKey wasn't actually written to the graph before, it
+    // will be rebuilt.
+    tester.eval(/*keepGoing=*/true, topKey);
+  }
+
+  @Test
+  public void continueWithErrorDep() throws Exception {
+    initializeTester();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    tester.getOrCreate(errorKey).setHasError(true);
+    tester.set("after", new StringValue("after"));
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    tester.getOrCreate(parentKey).addErrorDependency(errorKey, new StringValue("recovered"))
+        .setComputedValue(CONCATENATE).addDependency("after");
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/true, parentKey);
+    assertThat(result.errorMap()).isEmpty();
+    assertEquals("recoveredafter", result.get(parentKey).getValue());
+    tester.set("after", new StringValue("before"));
+    tester.invalidate();
+    result = tester.eval(/*keepGoing=*/true, parentKey);
+    assertThat(result.errorMap()).isEmpty();
+    assertEquals("recoveredbefore", result.get(parentKey).getValue());
+  }
+
+  @Test
+  public void continueWithErrorDepTurnedGood() throws Exception {
+    initializeTester();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    tester.getOrCreate(errorKey).setHasError(true);
+    tester.set("after", new StringValue("after"));
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    tester.getOrCreate(parentKey).addErrorDependency(errorKey, new StringValue("recovered"))
+        .setComputedValue(CONCATENATE).addDependency("after");
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/true, parentKey);
+    assertThat(result.errorMap()).isEmpty();
+    assertEquals("recoveredafter", result.get(parentKey).getValue());
+    tester.set(errorKey, new StringValue("reformed")).setHasError(false);
+    tester.invalidate();
+    result = tester.eval(/*keepGoing=*/true, parentKey);
+    assertThat(result.errorMap()).isEmpty();
+    assertEquals("reformedafter", result.get(parentKey).getValue());
+  }
+
+  @Test
+  public void errorDepAlreadyThereThenTurnedGood() throws Exception {
+    initializeTester();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    tester.getOrCreate(errorKey).setHasError(true);
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    tester.getOrCreate(parentKey).addErrorDependency(errorKey, new StringValue("recovered"))
+        .setHasError(true);
+    // Prime the graph by putting the error value in it beforehand.
+    assertThat(tester.evalAndGetError(errorKey).getRootCauses()).containsExactly(errorKey);
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, parentKey);
+    // Request the parent.
+    assertThat(result.getError(parentKey).getRootCauses()).containsExactly(parentKey).inOrder();
+    // Change the error value to no longer throw.
+    tester.set(errorKey, new StringValue("reformed")).setHasError(false);
+    tester.getOrCreate(parentKey, /*markAsModified=*/false).setHasError(false)
+        .setComputedValue(COPY);
+    tester.differencer.invalidate(ImmutableList.of(errorKey));
+    tester.invalidate();
+    // Request the parent again. This time it should succeed.
+    result = tester.eval(/*keepGoing=*/false, parentKey);
+    assertThat(result.errorMap()).isEmpty();
+    assertEquals("reformed", result.get(parentKey).getValue());
+    // Confirm that the parent no longer depends on the error transience value -- make it
+    // unbuildable again, but without invalidating it, and invalidate transient errors. The parent
+    // should not be rebuilt.
+    tester.getOrCreate(parentKey, /*markAsModified=*/false).setHasError(true);
+    tester.invalidateTransientErrors();
+    result = tester.eval(/*keepGoing=*/false, parentKey);
+    assertThat(result.errorMap()).isEmpty();
+    assertEquals("reformed", result.get(parentKey).getValue());
+  }
+
+  /**
+   * Regression test for 2014 bug: error transience value is registered before newly requested deps.
+   * A value requests a child, gets it back immediately, and then throws, causing the error
+   * transience value to be registered as a dep. The following build, the error is invalidated via
+   * that child.
+   */
+  @Test
+  public void doubleDepOnErrorTransienceValue() throws Exception {
+    initializeTester();
+    SkyKey leafKey = GraphTester.toSkyKey("leaf");
+    tester.set(leafKey, new StringValue("leaf"));
+    // Prime the graph by putting leaf in beforehand.
+    assertEquals(new StringValue("leaf"), tester.evalAndGet(/*keepGoing=*/false, leafKey));
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.getOrCreate(topKey).addDependency(leafKey).setHasError(true);
+    // Build top -- it has an error.
+    assertThat(tester.evalAndGetError(topKey).getRootCauses()).containsExactly(topKey).inOrder();
+    // Invalidate top via leaf, and rebuild.
+    tester.set(leafKey, new StringValue("leaf2"));
+    tester.invalidate();
+    assertThat(tester.evalAndGetError(topKey).getRootCauses()).containsExactly(topKey).inOrder();
+  }
+
+  /** Regression test for crash bug. */
+  @Test
+  public void errorTransienceDepCleared() throws Exception {
+    initializeTester();
+    final SkyKey top = GraphTester.toSkyKey("top");
+    SkyKey leaf = GraphTester.toSkyKey("leaf");
+    tester.set(leaf, new StringValue("leaf"));
+    tester.getOrCreate(top).addDependency(leaf).setHasTransientError(true);
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, top);
+    assertTrue(result.toString(), result.hasError());
+    tester.getOrCreate(leaf, /*markAsModified=*/true);
+    tester.invalidate();
+    SkyKey irrelevant = GraphTester.toSkyKey("irrelevant");
+    tester.set(irrelevant, new StringValue("irrelevant"));
+    tester.eval(/*keepGoing=*/true, irrelevant);
+    tester.invalidateTransientErrors();
+    result = tester.eval(/*keepGoing=*/true, top);
+    assertTrue(result.toString(), result.hasError());
+  }
+
+  @Test
+  public void incompleteValueAlreadyThereNotUsed() throws Exception {
+    initializeTester();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    tester.getOrCreate(errorKey).setHasError(true);
+    SkyKey midKey = GraphTester.toSkyKey("mid");
+    tester.getOrCreate(midKey).addErrorDependency(errorKey, new StringValue("recovered"))
+        .setComputedValue(COPY);
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    tester.getOrCreate(parentKey).addErrorDependency(midKey, new StringValue("don't use this"))
+        .setComputedValue(COPY);
+    // Prime the graph by evaluating the mid-level value. It shouldn't be stored in the graph
+    // because
+    // it was only called during the bubbling-up phase.
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, midKey);
+    assertEquals(null, result.get(midKey));
+    assertThat(result.getError().getRootCauses()).containsExactly(errorKey);
+    // In a keepGoing build, midKey should be re-evaluated.
+    assertEquals("recovered",
+        ((StringValue) tester.evalAndGet(/*keepGoing=*/true, parentKey)).getValue());
+  }
+
+  /**
+   * "top" requests a dependency group in which the first value, called "error", throws an
+   * exception, so "mid" and "mid2", which depend on "slow", never get built.
+   */
+  @Test
+  public void errorInDependencyGroup() throws Exception {
+    initializeTester();
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    CountDownLatch slowStart = new CountDownLatch(1);
+    CountDownLatch errorFinish = new CountDownLatch(1);
+    final SkyKey errorKey = GraphTester.toSkyKey("error");
+    tester.getOrCreate(errorKey).setBuilder(
+        new ChainedFunction(/*notifyStart=*/null, /*waitToFinish=*/slowStart,
+            /*notifyFinish=*/errorFinish, /*waitForException=*/false,
+            // ChainedFunction throws when value is null.
+            /*value=*/null, /*deps=*/ImmutableList.<SkyKey>of()));
+    SkyKey slowKey = GraphTester.toSkyKey("slow");
+    tester.getOrCreate(slowKey).setBuilder(
+        new ChainedFunction(/*notifyStart=*/slowStart, /*waitToFinish=*/errorFinish,
+            /*notifyFinish=*/null, /*waitForException=*/true,
+            new StringValue("slow"), /*deps=*/ImmutableList.<SkyKey>of()));
+    final SkyKey midKey = GraphTester.toSkyKey("mid");
+    tester.getOrCreate(midKey).addDependency(slowKey).setComputedValue(COPY);
+    final SkyKey mid2Key = GraphTester.toSkyKey("mid2");
+    tester.getOrCreate(mid2Key).addDependency(slowKey).setComputedValue(COPY);
+    tester.set(topKey, null);
+    tester.getOrCreate(topKey).setBuilder(new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException,
+          InterruptedException {
+        env.getValues(ImmutableList.of(errorKey, midKey, mid2Key));
+        if (env.valuesMissing()) {
+          return null;
+        }
+        return new StringValue("top");
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        return null;
+      }
+    });
+
+    // Assert that build fails and "error" really is in error.
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, topKey);
+    assertTrue(result.hasError());
+    assertThat(result.getError(topKey).getRootCauses()).containsExactly(errorKey);
+
+    // Ensure that evaluation succeeds if errorKey does not throw an error.
+    tester.getOrCreate(errorKey).setBuilder(null);
+    tester.set(errorKey, new StringValue("ok"));
+    tester.invalidate();
+    assertEquals(new StringValue("top"), tester.evalAndGet("top"));
+  }
+
+  /**
+   * Regression test -- if value top requests {depA, depB}, depC, with depA and depC there and depB
+   * absent, and then throws an exception, the stored deps should be depA, depC (in different
+   * groups), not {depA, depC} (same group).
+   */
+  @Test
+  public void valueInErrorWithGroups() throws Exception {
+    initializeTester();
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    final SkyKey groupDepA = GraphTester.toSkyKey("groupDepA");
+    final SkyKey groupDepB = GraphTester.toSkyKey("groupDepB");
+    SkyKey depC = GraphTester.toSkyKey("depC");
+    tester.set(groupDepA, new StringValue("depC"));
+    tester.set(groupDepB, new StringValue(""));
+    tester.getOrCreate(depC).setHasError(true);
+    tester.getOrCreate(topKey).setBuilder(new NoExtractorFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException {
+        StringValue val = ((StringValue) env.getValues(
+            ImmutableList.of(groupDepA, groupDepB)).get(groupDepA));
+        if (env.valuesMissing()) {
+          return null;
+        }
+        String nextDep = val.getValue();
+        try {
+          env.getValueOrThrow(GraphTester.toSkyKey(nextDep), SomeErrorException.class);
+        } catch (SomeErrorException e) {
+          throw new GenericFunctionException(e, Transience.PERSISTENT);
+        }
+        return env.valuesMissing() ? null : new StringValue("top");
+      }
+    });
+
+    EvaluationResult<StringValue> evaluationResult = tester.eval(
+        /*keepGoing=*/true, groupDepA, depC);
+    assertTrue(evaluationResult.hasError());
+    assertEquals("depC", evaluationResult.get(groupDepA).getValue());
+    assertThat(evaluationResult.getError(depC).getRootCauses()).containsExactly(depC).inOrder();
+    evaluationResult = tester.eval(/*keepGoing=*/false, topKey);
+    assertTrue(evaluationResult.hasError());
+    assertThat(evaluationResult.getError(topKey).getRootCauses()).containsExactly(topKey).inOrder();
+
+    tester.set(groupDepA, new StringValue("groupDepB"));
+    tester.getOrCreate(depC, /*markAsModified=*/true);
+    tester.invalidate();
+    evaluationResult = tester.eval(/*keepGoing=*/false, topKey);
+    assertFalse(evaluationResult.toString(), evaluationResult.hasError());
+    assertEquals("top", evaluationResult.get(topKey).getValue());
+  }
+
+  @Test
+  public void errorOnlyEmittedOnce() throws Exception {
+    initializeTester();
+    tester.set("x", new StringValue("y")).setWarning("fizzlepop");
+    StringValue value = (StringValue) tester.evalAndGet("x");
+    assertEquals("y", value.getValue());
+    JunitTestUtils.assertContainsEvent(eventCollector, "fizzlepop");
+    JunitTestUtils.assertEventCount(1, eventCollector);
+
+    tester.invalidate();
+    value = (StringValue) tester.evalAndGet("x");
+    assertEquals("y", value.getValue());
+    // No new events emitted.
+    JunitTestUtils.assertEventCount(1, eventCollector);
+  }
+
+  /**
+   * We are checking here that we are resilient to a race condition in which a value that is
+   * checking its children for dirtiness is signaled by all of its children, putting it in a ready
+   * state, before the thread has terminated. Optionally, one of its children may throw an error,
+   * shutting down the threadpool. This is similar to
+   * {@link ParallelEvaluatorTest#slowChildCleanup}: a child about to throw signals its parent and
+   * the parent's builder restarts itself before the exception is thrown. Here, the signaling
+   * happens while dirty dependencies are being checked, as opposed to during actual evaluation, but
+   * the principle is the same. We control the timing by blocking "top"'s registering itself on its
+   * deps.
+   */
+  private void dirtyChildEnqueuesParentDuringCheckDependencies(final boolean throwError)
+      throws Exception {
+    // Value to be built. It will be signaled to rebuild before it has finished checking its deps.
+    final SkyKey top = GraphTester.toSkyKey("top");
+    // Dep that blocks before it acknowledges being added as a dep by top, so the firstKey value has
+    // time to signal top.
+    final SkyKey slowAddingDep = GraphTester.toSkyKey("dep");
+    // Don't perform any blocking on the first build.
+    final AtomicBoolean delayTopSignaling = new AtomicBoolean(false);
+    final CountDownLatch topSignaled = new CountDownLatch(1);
+    final CountDownLatch topRestartedBuild = new CountDownLatch(1);
+    final TrackingAwaiter trackingAwaiter = new TrackingAwaiter();
+    setGraphForTesting(new NotifyingInMemoryGraph(new Listener() {
+      @Override
+      public void accept(SkyKey key, EventType type, Order order, Object context) {
+        if (!delayTopSignaling.get()) {
+          return;
+        }
+        if (key.equals(top) && type == EventType.SIGNAL && order == Order.AFTER) {
+          // top is signaled by firstKey (since slowAddingDep is blocking), so slowAddingDep is now
+          // free to acknowledge top as a parent.
+          topSignaled.countDown();
+          return;
+        }
+        if (key.equals(slowAddingDep) && type == EventType.ADD_REVERSE_DEP
+            && context.equals(top) && order == Order.BEFORE) {
+          // If top is trying to declare a dep on slowAddingDep, wait until firstKey has signaled
+          // top. Then this add dep will return DONE and top will be signaled, making it ready, so
+          // it will be enqueued.
+          trackingAwaiter.awaitLatchAndTrackExceptions(topSignaled,
+              "first key didn't signal top in time");
+        }
+      }
+    }));
+    // Value that is modified on the second build. Its thread won't finish until it signals top,
+    // which will wait for the signal before it enqueues its next dep. We prevent the thread from
+    // finishing by having the listener to which it reports its warning block until top's builder
+    // starts.
+    final SkyKey firstKey = GraphTester.skyKey("first");
+    tester.set(firstKey, new StringValue("biding"));
+    tester.set(slowAddingDep, new StringValue("dep"));
+    final AtomicInteger numTopInvocations = new AtomicInteger(0);
+    tester.getOrCreate(top).setBuilder(new NoExtractorFunction() {
+      @Override
+      public SkyValue compute(SkyKey key, SkyFunction.Environment env) {
+        numTopInvocations.incrementAndGet();
+        if (delayTopSignaling.get()) {
+          // The reporter will be given firstKey's warning to emit when it is requested as a dep
+          // below, if firstKey is already built, so we release the reporter's latch beforehand.
+          topRestartedBuild.countDown();
+        }
+        // top's builder just requests both deps in a group.
+        env.getValuesOrThrow(ImmutableList.of(firstKey, slowAddingDep), SomeErrorException.class);
+        return env.valuesMissing() ? null : new StringValue("top");
+      }
+    });
+    reporter = new DelegatingEventHandler(reporter) {
+      @Override
+      public void handle(Event e) {
+        super.handle(e);
+        if (e.getKind() == EventKind.WARNING) {
+          if (!throwError) {
+            trackingAwaiter.awaitLatchAndTrackExceptions(topRestartedBuild,
+                "top's builder did not start in time");
+          }
+        }
+      }
+    };
+    // First build : just prime the graph.
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, top);
+    assertFalse(result.hasError());
+    assertEquals(new StringValue("top"), result.get(top));
+    assertEquals(2, numTopInvocations.get());
+    // Now dirty the graph, and maybe have firstKey throw an error.
+    String warningText = "warning text";
+    tester.getOrCreate(firstKey, /*markAsModified=*/true).setHasError(throwError)
+        .setWarning(warningText);
+    tester.invalidate();
+    delayTopSignaling.set(true);
+    result = tester.eval(/*keepGoing=*/false, top);
+    trackingAwaiter.assertNoErrors();
+    if (throwError) {
+      assertTrue(result.hasError());
+      assertThat(result.keyNames()).isEmpty(); // No successfully evaluated values.
+      ErrorInfo errorInfo = result.getError(top);
+      assertThat(errorInfo.getRootCauses()).containsExactly(firstKey);
+      assertEquals("on the incremental build, top's builder should have only been used in error "
+          + "bubbling", 3, numTopInvocations.get());
+    } else {
+      assertEquals(new StringValue("top"), result.get(top));
+      assertFalse(result.hasError());
+      assertEquals("on the incremental build, top's builder should have only been executed once in "
+          + "normal evaluation", 3, numTopInvocations.get());
+    }
+    JunitTestUtils.assertContainsEvent(eventCollector, warningText);
+    assertEquals(0, topSignaled.getCount());
+    assertEquals(0, topRestartedBuild.getCount());
+  }
+
+  @Test
+  public void dirtyChildEnqueuesParentDuringCheckDependencies_ThrowDoesntEnqueue()
+      throws Exception {
+    dirtyChildEnqueuesParentDuringCheckDependencies(/*throwError=*/true);
+  }
+
+  @Test
+  public void dirtyChildEnqueuesParentDuringCheckDependencies_NoThrow() throws Exception {
+    dirtyChildEnqueuesParentDuringCheckDependencies(/*throwError=*/false);
+  }
+
+  /**
+   * The same dep is requested in two groups, but its value determines what the other dep in the
+   * second group is. When it changes, the other dep in the second group should not be requested.
+   */
+  @Test
+  public void sameDepInTwoGroups() throws Exception {
+    initializeTester();
+
+    // leaf4 should not built in the second build.
+    final SkyKey leaf4 = GraphTester.toSkyKey("leaf4");
+    final AtomicBoolean shouldNotBuildLeaf4 = new AtomicBoolean(false);
+    setGraphForTesting(new NotifyingInMemoryGraph(new Listener() {
+      @Override
+      public void accept(SkyKey key, EventType type, Order order, Object context) {
+        if (shouldNotBuildLeaf4.get() && key.equals(leaf4)) {
+          throw new IllegalStateException("leaf4 should not have been considered this build: "
+              + type + ", " + order + ", " + context);
+        }
+      }
+    }));
+    tester.set(leaf4, new StringValue("leaf4"));
+
+    // Create leaf0, leaf1 and leaf2 values with values "leaf2", "leaf3", "leaf4" respectively.
+    // These will be requested as one dependency group. In the second build, leaf2 will have the
+    // value "leaf5".
+    final List<SkyKey> leaves = new ArrayList<>();
+    for (int i = 0; i <= 2; i++) {
+      SkyKey leaf = GraphTester.toSkyKey("leaf" + i);
+      leaves.add(leaf);
+      tester.set(leaf, new StringValue("leaf" + (i + 2)));
+    }
+
+    // Create "top" value. It depends on all leaf values in two overlapping dependency groups.
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    final SkyValue topValue = new StringValue("top");
+    tester.getOrCreate(topKey).setBuilder(new NoExtractorFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException,
+          InterruptedException {
+        // Request the first group, [leaf0, leaf1, leaf2].
+        // In the first build, it has values ["leaf2", "leaf3", "leaf4"].
+        // In the second build it has values ["leaf2", "leaf3", "leaf5"]
+        Map<SkyKey, SkyValue> values = env.getValues(leaves);
+        if (env.valuesMissing()) {
+          return null;
+        }
+
+        // Request the second group. In the first build it's [leaf2, leaf4].
+        // In the second build it's [leaf2, leaf5]
+        env.getValues(ImmutableList.of(leaves.get(2),
+            GraphTester.toSkyKey(((StringValue) values.get(leaves.get(2))).getValue())));
+        if (env.valuesMissing()) {
+          return null;
+        }
+
+        return topValue;
+      }
+    });
+
+    // First build: assert we can evaluate "top".
+    assertEquals(topValue, tester.evalAndGet(/*keepGoing=*/false, topKey));
+
+    // Second build: replace "leaf4" by "leaf5" in leaf2's value. Assert leaf4 is not requested.
+    final SkyKey leaf5 = GraphTester.toSkyKey("leaf5");
+    tester.set(leaf5, new StringValue("leaf5"));
+    tester.set(leaves.get(2), new StringValue("leaf5"));
+    tester.invalidate();
+    shouldNotBuildLeaf4.set(true);
+    assertEquals(topValue, tester.evalAndGet(/*keepGoing=*/false, topKey));
+  }
+
+  @Test
+  public void dirtyAndChanged() throws Exception {
+    initializeTester();
+    SkyKey leaf = GraphTester.toSkyKey("leaf");
+    SkyKey mid = GraphTester.toSkyKey("mid");
+    SkyKey top = GraphTester.toSkyKey("top");
+    tester.getOrCreate(top).addDependency(mid).setComputedValue(COPY);
+    tester.getOrCreate(mid).addDependency(leaf).setComputedValue(COPY);
+    tester.set(leaf, new StringValue("leafy"));
+    // For invalidation.
+    tester.set("dummy", new StringValue("dummy"));
+    StringValue topValue = (StringValue) tester.evalAndGet("top");
+    assertEquals("leafy", topValue.getValue());
+    tester.set(leaf, new StringValue("crunchy"));
+    tester.invalidate();
+    // For invalidation.
+    tester.evalAndGet("dummy");
+    tester.getOrCreate(mid, /*markAsModified=*/true);
+    tester.invalidate();
+    topValue = (StringValue) tester.evalAndGet("top");
+    assertEquals("crunchy", topValue.getValue());
+  }
+
+  /**
+   * Test whether a value that was already marked changed will be incorrectly marked dirty, not
+   * changed, if another thread tries to mark it just dirty. To exercise this, we need to have a
+   * race condition where both threads see that the value is not dirty yet, then the "changed"
+   * thread marks the value changed before the "dirty" thread marks the value dirty. To accomplish
+   * this, we use a countdown latch to make the "dirty" thread wait until the "changed" thread is
+   * done, and another countdown latch to make both of them wait until they have both checked if the
+   * value is currently clean.
+   */
+  @Test
+  public void dirtyAndChangedValueIsChanged() throws Exception {
+    final SkyKey parent = GraphTester.toSkyKey("parent");
+    final AtomicBoolean blockingEnabled = new AtomicBoolean(false);
+    final CountDownLatch waitForChanged = new CountDownLatch(1);
+    // changed thread checks value entry once (to see if it is changed). dirty thread checks twice,
+    // to see if it is changed, and if it is dirty.
+    final CountDownLatch threadsStarted = new CountDownLatch(3);
+    final TrackingAwaiter trackingAwaiter = new TrackingAwaiter();
+    setGraphForTesting(new NotifyingInMemoryGraph(new Listener() {
+      @Override
+      public void accept(SkyKey key, EventType type, Order order, Object context) {
+        if (!blockingEnabled.get()) {
+          return;
+        }
+        if (!key.equals(parent)) {
+          return;
+        }
+        if (type == EventType.IS_CHANGED && order == Order.BEFORE) {
+          threadsStarted.countDown();
+        }
+        // Dirtiness only checked by dirty thread.
+        if (type == EventType.IS_DIRTY && order == Order.BEFORE) {
+          threadsStarted.countDown();
+        }
+        if (type == EventType.MARK_DIRTY) {
+          trackingAwaiter.awaitLatchAndTrackExceptions(threadsStarted,
+              "Both threads did not query if value isChanged in time");
+          boolean isChanged = (Boolean) context;
+          if (order == Order.BEFORE && !isChanged) {
+            trackingAwaiter.awaitLatchAndTrackExceptions(waitForChanged,
+                "'changed' thread did not mark value changed in time");
+            return;
+          }
+          if (order == Order.AFTER && isChanged) {
+            waitForChanged.countDown();
+          }
+        }
+      }
+    }));
+    SkyKey leaf = GraphTester.toSkyKey("leaf");
+    tester.set(leaf, new StringValue("leaf"));
+    tester.getOrCreate(parent).addDependency(leaf).setComputedValue(CONCATENATE);
+    EvaluationResult<StringValue> result;
+    result = tester.eval(/*keepGoing=*/false, parent);
+    assertEquals("leaf", result.get(parent).getValue());
+    // Invalidate leaf, but don't actually change it. It will transitively dirty parent
+    // concurrently with parent directly dirtying itself.
+    tester.getOrCreate(leaf, /*markAsModified=*/true);
+    SkyKey other2 = GraphTester.toSkyKey("other2");
+    tester.set(other2, new StringValue("other2"));
+    // Invalidate parent, actually changing it.
+    tester.getOrCreate(parent, /*markAsModified=*/true).addDependency(other2);
+    tester.invalidate();
+    blockingEnabled.set(true);
+    result = tester.eval(/*keepGoing=*/false, parent);
+    assertEquals("leafother2", result.get(parent).getValue());
+    trackingAwaiter.assertNoErrors();
+    assertEquals(0, waitForChanged.getCount());
+    assertEquals(0, threadsStarted.getCount());
+  }
+
+  @Test
+  public void singleValueDependsOnManyDirtyValues() throws Exception {
+    initializeTester();
+    SkyKey[] values = new SkyKey[TEST_NODE_COUNT];
+    StringBuilder expected = new StringBuilder();
+    for (int i = 0; i < values.length; i++) {
+      String valueName = Integer.toString(i);
+      values[i] = GraphTester.toSkyKey(valueName);
+      tester.set(values[i], new StringValue(valueName));
+      expected.append(valueName);
+    }
+    SkyKey topKey = new SkyKey(GraphTester.NODE_TYPE, "top");
+    TestFunction value = tester.getOrCreate(topKey)
+        .setComputedValue(CONCATENATE);
+    for (int i = 0; i < values.length; i++) {
+      value.addDependency(values[i]);
+    }
+
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, topKey);
+    assertEquals(new StringValue(expected.toString()), result.get(topKey));
+
+    for (int j = 0; j < RUNS; j++) {
+      for (int i = 0; i < values.length; i++) {
+        tester.getOrCreate(values[i], /*markAsModified=*/true);
+      }
+      // This value has an error, but we should never discover it because it is not marked changed
+      // and all of its dependencies re-evaluate to the same thing.
+      tester.getOrCreate(topKey, /*markAsModified=*/false).setHasError(true);
+      tester.invalidate();
+
+      result = tester.eval(/*keep_going=*/false, topKey);
+      assertEquals(new StringValue(expected.toString()), result.get(topKey));
+    }
+  }
+
+  /**
+   * Tests scenario where we have dirty values in the graph, and then one of them is deleted since
+   * its evaluation did not complete before an error was thrown. Can either test the graph via an
+   * evaluation of that deleted value, or an invalidation of a child, and can either remove the
+   * thrown error or throw it again on that evaluation.
+   */
+  private void dirtyValueChildrenProperlyRemovedOnEarlyBuildAbort(
+      boolean reevaluateMissingValue, boolean removeError) throws Exception {
+    initializeTester();
+    SkyKey errorKey = GraphTester.toSkyKey("error");
+    tester.set(errorKey, new StringValue("biding time"));
+    SkyKey slowKey = GraphTester.toSkyKey("slow");
+    tester.set(slowKey, new StringValue("slow"));
+    SkyKey midKey = GraphTester.toSkyKey("mid");
+    tester.getOrCreate(midKey).addDependency(slowKey).setComputedValue(COPY);
+    SkyKey lastKey = GraphTester.toSkyKey("last");
+    tester.set(lastKey, new StringValue("last"));
+    SkyKey motherKey = GraphTester.toSkyKey("mother");
+    tester.getOrCreate(motherKey).addDependency(errorKey)
+        .addDependency(midKey).addDependency(lastKey).setComputedValue(CONCATENATE);
+    SkyKey fatherKey = GraphTester.toSkyKey("father");
+    tester.getOrCreate(fatherKey).addDependency(errorKey)
+        .addDependency(midKey).addDependency(lastKey).setComputedValue(CONCATENATE);
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, motherKey, fatherKey);
+    assertEquals("biding timeslowlast", result.get(motherKey).getValue());
+    assertEquals("biding timeslowlast", result.get(fatherKey).getValue());
+    tester.set(slowKey, null);
+    // Each parent depends on errorKey, midKey, lastKey. We keep slowKey waiting until errorKey is
+    // finished. So there is no way lastKey can be enqueued by either parent. Thus, the parent that
+    // is cleaned has not interacted with lastKey this build. Still, lastKey's reverse dep on that
+    // parent should be removed.
+    CountDownLatch errorFinish = new CountDownLatch(1);
+    tester.set(errorKey, null);
+    tester.getOrCreate(errorKey).setBuilder(
+        new ChainedFunction(/*notifyStart=*/null, /*waitToFinish=*/null,
+            /*notifyFinish=*/errorFinish, /*waitForException=*/false, /*value=*/null,
+            /*deps=*/ImmutableList.<SkyKey>of()));
+    tester.getOrCreate(slowKey).setBuilder(
+        new ChainedFunction(/*notifyStart=*/null, /*waitToFinish=*/errorFinish,
+            /*notifyFinish=*/null, /*waitForException=*/true, new StringValue("leaf2"),
+            /*deps=*/ImmutableList.<SkyKey>of()));
+    tester.invalidate();
+    // errorKey finishes, written to graph -> leafKey maybe starts+finishes & (Visitor aborts)
+    // -> one of mother or father builds. The other one should be cleaned, and no references to it
+    // left in the graph.
+    result = tester.eval(/*keepGoing=*/false, motherKey, fatherKey);
+    assertTrue(result.hasError());
+    // Only one of mother or father should be in the graph.
+    assertTrue(result.getError(motherKey) + ", " + result.getError(fatherKey),
+        (result.getError(motherKey) == null) != (result.getError(fatherKey) == null));
+    SkyKey parentKey = (reevaluateMissingValue == (result.getError(motherKey) == null))
+        ? motherKey : fatherKey;
+    // Give slowKey a nice ordinary builder.
+    tester.getOrCreate(slowKey, /*markAsModified=*/false).setBuilder(null)
+        .setConstantValue(new StringValue("leaf2"));
+    if (removeError) {
+      tester.getOrCreate(errorKey, /*markAsModified=*/true).setBuilder(null)
+          .setConstantValue(new StringValue("reformed"));
+    }
+    String lastString = "last";
+    if (!reevaluateMissingValue) {
+      // Mark the last key modified if we're not trying the absent value again. This invalidation
+      // will test if lastKey still has a reference to the absent value.
+      lastString = "last2";
+      tester.set(lastKey, new StringValue(lastString));
+    }
+    tester.invalidate();
+    result = tester.eval(/*keepGoing=*/false, parentKey);
+    if (removeError) {
+      assertEquals("reformedleaf2" + lastString, result.get(parentKey).getValue());
+    } else {
+      assertNotNull(result.getError(parentKey));
+    }
+  }
+
+  /**
+   * The following four tests (dirtyChildrenProperlyRemovedWith*) test the consistency of the graph
+   * after a failed build in which a dirty value should have been deleted from the graph. The
+   * consistency is tested via either evaluating the missing value, or the re-evaluating the present
+   * value, and either clearing the error or keeping it. To evaluate the present value, we
+   * invalidate the error value to force re-evaluation. Related to bug "skyframe m1: graph may not
+   * be properly cleaned on interrupt or failure".
+   */
+  @Test
+  public void dirtyChildrenProperlyRemovedWithInvalidateRemoveError() throws Exception {
+    dirtyValueChildrenProperlyRemovedOnEarlyBuildAbort(/*reevaluateMissingValue=*/false,
+        /*removeError=*/true);
+  }
+
+  @Test
+  public void dirtyChildrenProperlyRemovedWithInvalidateKeepError() throws Exception {
+    dirtyValueChildrenProperlyRemovedOnEarlyBuildAbort(/*reevaluateMissingValue=*/false,
+        /*removeError=*/false);
+  }
+
+  @Test
+  public void dirtyChildrenProperlyRemovedWithReevaluateRemoveError() throws Exception {
+    dirtyValueChildrenProperlyRemovedOnEarlyBuildAbort(/*reevaluateMissingValue=*/true,
+        /*removeError=*/true);
+  }
+
+  @Test
+  public void dirtyChildrenProperlyRemovedWithReevaluateKeepError() throws Exception {
+    dirtyValueChildrenProperlyRemovedOnEarlyBuildAbort(/*reevaluateMissingValue=*/true,
+        /*removeError=*/false);
+  }
+
+  /**
+   * Regression test: enqueue so many values that some of them won't have started processing, and
+   * then either interrupt processing or have a child throw an error. In the latter case, this also
+   * tests that a value that hasn't started processing can still have a child error bubble up to it.
+   * In both cases, it tests that the graph is properly cleaned of the dirty values and references
+   * to them.
+   */
+  private void manyDirtyValuesClearChildrenOnFail(boolean interrupt) throws Exception {
+    SkyKey leafKey = GraphTester.toSkyKey("leaf");
+    tester.set(leafKey, new StringValue("leafy"));
+    SkyKey lastKey = GraphTester.toSkyKey("last");
+    tester.set(lastKey, new StringValue("last"));
+    final List<SkyKey> tops = new ArrayList<>();
+    // Request far more top-level values than there are threads, so some of them will block until
+    // the
+    // leaf child is enqueued for processing.
+    for (int i = 0; i < 10000; i++) {
+      SkyKey topKey = GraphTester.toSkyKey("top" + i);
+      tester.getOrCreate(topKey).addDependency(leafKey).addDependency(lastKey)
+          .setComputedValue(CONCATENATE);
+      tops.add(topKey);
+    }
+    tester.eval(/*keepGoing=*/false, tops.toArray(new SkyKey[0]));
+    final CountDownLatch notifyStart = new CountDownLatch(1);
+    tester.set(leafKey, null);
+    if (interrupt) {
+      // leaf will wait for an interrupt if desired. We cannot use the usual ChainedFunction
+      // because we need to actually throw the interrupt.
+      final AtomicBoolean shouldSleep = new AtomicBoolean(true);
+      tester.getOrCreate(leafKey, /*markAsModified=*/true).setBuilder(
+          new NoExtractorFunction() {
+            @Override
+            public SkyValue compute(SkyKey skyKey, Environment env) throws InterruptedException {
+              notifyStart.countDown();
+              if (shouldSleep.get()) {
+                // Should be interrupted within 5 seconds.
+                Thread.sleep(5000);
+                throw new AssertionError("leaf was not interrupted");
+              }
+              return new StringValue("crunchy");
+            }
+          });
+      tester.invalidate();
+      TestThread evalThread = new TestThread() {
+        @Override
+        public void runTest() {
+          try {
+            tester.eval(/*keepGoing=*/false, tops.toArray(new SkyKey[0]));
+            Assert.fail();
+          } catch (InterruptedException e) {
+            // Expected.
+          }
+        }
+      };
+      evalThread.start();
+      assertTrue(notifyStart.await(TestUtils.WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+      evalThread.interrupt();
+      evalThread.joinAndAssertState(TestUtils.WAIT_TIMEOUT_MILLISECONDS);
+      // Free leafKey to compute next time.
+      shouldSleep.set(false);
+    } else {
+      // Non-interrupt case. Just throw an error in the child.
+      tester.getOrCreate(leafKey, /*markAsModified=*/true).setHasError(true);
+      tester.invalidate();
+      // The error thrown may non-deterministically bubble up to a parent that has not yet started
+      // processing, but has been enqueued for processing.
+      tester.eval(/*keepGoing=*/false, tops.toArray(new SkyKey[0]));
+      tester.getOrCreate(leafKey, /*markAsModified=*/true).setHasError(false);
+      tester.set(leafKey, new StringValue("crunchy"));
+    }
+    // lastKey was not touched during the previous build, but its reverse deps on its parents should
+    // still be accurate.
+    tester.set(lastKey, new StringValue("new last"));
+    tester.invalidate();
+    EvaluationResult<StringValue> result =
+        tester.eval(/*keepGoing=*/false, tops.toArray(new SkyKey[0]));
+    for (SkyKey topKey : tops) {
+      assertEquals(topKey.toString(), "crunchynew last", result.get(topKey).getValue());
+    }
+  }
+
+  /**
+   * Regression test: make sure that if an evaluation fails before a dirty value starts evaluation
+   * (in particular, before it is reset), the graph remains consistent.
+   */
+  @Test
+  public void manyDirtyValuesClearChildrenOnError() throws Exception {
+    manyDirtyValuesClearChildrenOnFail(/*interrupt=*/false);
+  }
+
+  /**
+   * Regression test: Make sure that if an evaluation is interrupted before a dirty value starts
+   * evaluation (in particular, before it is reset), the graph remains consistent.
+   */
+  @Test
+  public void manyDirtyValuesClearChildrenOnInterrupt() throws Exception {
+    manyDirtyValuesClearChildrenOnFail(/*interrupt=*/true);
+  }
+
+  /**
+   * Regression test for case where the user requests that we delete nodes that are already in the
+   * queue to be dirtied. We should handle that gracefully and not complain.
+   */
+  @Test
+  public void deletingDirtyNodes() throws Exception {
+    final Thread thread = Thread.currentThread();
+    final AtomicBoolean interruptInvalidation = new AtomicBoolean(false);
+    initializeTester(new TrackingInvalidationReceiver() {
+      private final AtomicBoolean firstInvalidation = new AtomicBoolean(true);
+      @Override
+      public void invalidated(SkyValue value, InvalidationState state) {
+        if (interruptInvalidation.get() && !firstInvalidation.getAndSet(false)) {
+          thread.interrupt();
+        }
+        super.invalidated(value, state);
+      }
+    });
+    SkyKey key = null;
+    // Create a long chain of nodes. Most of them will not actually be dirtied, but the last one to
+    // be dirtied will enqueue its parent for dirtying, so it will be in the queue for the next run.
+    for (int i = 0; i < TEST_NODE_COUNT; i++) {
+      key = GraphTester.toSkyKey("node" + i);
+      if (i > 0) {
+        tester.getOrCreate(key).addDependency("node" + (i - 1)).setComputedValue(COPY);
+      } else {
+        tester.set(key, new StringValue("node0"));
+      }
+    }
+    // Seed the graph.
+    assertEquals("node0", ((StringValue) tester.evalAndGet(/*keepGoing=*/false, key)).getValue());
+    // Start the dirtying process.
+    tester.set("node0", new StringValue("new"));
+    tester.invalidate();
+    interruptInvalidation.set(true);
+    try {
+      tester.eval(/*keepGoing=*/false, key);
+      fail();
+    } catch (InterruptedException e) {
+      // Expected.
+    }
+    interruptInvalidation.set(false);
+    // Now delete all the nodes. The node that was going to be dirtied is also deleted, which we
+    // should handle.
+    tester.graph.delete(Predicates.<SkyKey>alwaysTrue());
+    assertEquals("new", ((StringValue) tester.evalAndGet(/*keepGoing=*/false, key)).getValue());
+  }
+
+  @Test
+  public void changePruning() throws Exception {
+    initializeTester();
+    SkyKey leaf = GraphTester.toSkyKey("leaf");
+    SkyKey mid = GraphTester.toSkyKey("mid");
+    SkyKey top = GraphTester.toSkyKey("top");
+    tester.getOrCreate(top).addDependency(mid).setComputedValue(COPY);
+    tester.getOrCreate(mid).addDependency(leaf).setComputedValue(COPY);
+    tester.set(leaf, new StringValue("leafy"));
+    StringValue topValue = (StringValue) tester.evalAndGet("top");
+    assertEquals("leafy", topValue.getValue());
+    // Mark leaf changed, but don't actually change it.
+    tester.getOrCreate(leaf, /*markAsModified=*/true);
+    // mid will give an error if re-evaluated, but it shouldn't be because it is not marked changed,
+    // and its dirty child will evaluate to the same element.
+    tester.getOrCreate(mid, /*markAsModified=*/false).setHasError(true);
+    tester.invalidate();
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, top);
+    assertFalse(result.hasError());
+    topValue = result.get(top);
+    assertEquals("leafy", topValue.getValue());
+    assertThat(tester.getDirtyValues()).isEmpty();
+    assertThat(tester.getDeletedValues()).isEmpty();
+  }
+
+  @Test
+  public void changePruningWithDoneValue() throws Exception {
+    initializeTester();
+    SkyKey leaf = GraphTester.toSkyKey("leaf");
+    SkyKey mid = GraphTester.toSkyKey("mid");
+    SkyKey top = GraphTester.toSkyKey("top");
+    SkyKey suffix = GraphTester.toSkyKey("suffix");
+    StringValue suffixValue = new StringValue("suffix");
+    tester.set(suffix, suffixValue);
+    tester.getOrCreate(top).addDependency(mid).addDependency(suffix).setComputedValue(CONCATENATE);
+    tester.getOrCreate(mid).addDependency(leaf).addDependency(suffix).setComputedValue(CONCATENATE);
+    SkyValue leafyValue = new StringValue("leafy");
+    tester.set(leaf, leafyValue);
+    StringValue value = (StringValue) tester.evalAndGet("top");
+    assertEquals("leafysuffixsuffix", value.getValue());
+    // Mark leaf changed, but don't actually change it.
+    tester.getOrCreate(leaf, /*markAsModified=*/true);
+    // mid will give an error if re-evaluated, but it shouldn't be because it is not marked changed,
+    // and its dirty child will evaluate to the same element.
+    tester.getOrCreate(mid, /*markAsModified=*/false).setHasError(true);
+    tester.invalidate();
+    value = (StringValue) tester.evalAndGet("leaf");
+    assertEquals("leafy", value.getValue());
+    assertThat(tester.getDirtyValues()).containsExactly(new StringValue("leafysuffix"),
+        new StringValue("leafysuffixsuffix"));
+    assertThat(tester.getDeletedValues()).isEmpty();
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, top);
+    assertFalse(result.hasError());
+    value = result.get(top);
+    assertEquals("leafysuffixsuffix", value.getValue());
+    assertThat(tester.getDirtyValues()).isEmpty();
+    assertThat(tester.getDeletedValues()).isEmpty();
+  }
+
+  @Test
+  public void changedChildChangesDepOfParent() throws Exception {
+    initializeTester();
+    final SkyKey buildFile = GraphTester.toSkyKey("buildFile");
+    ValueComputer authorDrink = new ValueComputer() {
+      @Override
+      public SkyValue compute(Map<SkyKey, SkyValue> deps, SkyFunction.Environment env) {
+        String author = ((StringValue) deps.get(buildFile)).getValue();
+        StringValue beverage;
+        switch (author) {
+          case "hemingway":
+            beverage = (StringValue) env.getValue(GraphTester.toSkyKey("absinthe"));
+            break;
+          case "joyce":
+            beverage = (StringValue) env.getValue(GraphTester.toSkyKey("whiskey"));
+            break;
+          default:
+              throw new IllegalStateException(author);
+        }
+        if (beverage == null) {
+          return null;
+        }
+        return new StringValue(author + " drank " + beverage.getValue());
+      }
+    };
+
+    tester.set(buildFile, new StringValue("hemingway"));
+    SkyKey absinthe = GraphTester.toSkyKey("absinthe");
+    tester.set(absinthe, new StringValue("absinthe"));
+    SkyKey whiskey = GraphTester.toSkyKey("whiskey");
+    tester.set(whiskey, new StringValue("whiskey"));
+    SkyKey top = GraphTester.toSkyKey("top");
+    tester.getOrCreate(top).addDependency(buildFile).setComputedValue(authorDrink);
+    StringValue topValue = (StringValue) tester.evalAndGet("top");
+    assertEquals("hemingway drank absinthe", topValue.getValue());
+    tester.set(buildFile, new StringValue("joyce"));
+    // Don't evaluate absinthe successfully anymore.
+    tester.getOrCreate(absinthe).setHasError(true);
+    tester.invalidate();
+    topValue = (StringValue) tester.evalAndGet("top");
+    assertEquals("joyce drank whiskey", topValue.getValue());
+    assertThat(tester.getDirtyValues()).containsExactly(new StringValue("hemingway"),
+        new StringValue("hemingway drank absinthe"));
+    assertThat(tester.getDeletedValues()).isEmpty();
+  }
+
+  @Test
+  public void dirtyDepIgnoresChildren() throws Exception {
+    initializeTester();
+    SkyKey leaf = GraphTester.toSkyKey("leaf");
+    SkyKey mid = GraphTester.toSkyKey("mid");
+    SkyKey top = GraphTester.toSkyKey("top");
+    tester.set(mid, new StringValue("ignore"));
+    tester.getOrCreate(top).addDependency(mid).setComputedValue(COPY);
+    tester.getOrCreate(mid).addDependency(leaf);
+    tester.set(leaf, new StringValue("leafy"));
+    StringValue topValue = (StringValue) tester.evalAndGet("top");
+    assertEquals("ignore", topValue.getValue());
+    assertThat(tester.getDirtyValues()).isEmpty();
+    assertThat(tester.getDeletedValues()).isEmpty();
+    // Change leaf.
+    tester.set(leaf, new StringValue("crunchy"));
+    tester.invalidate();
+    topValue = (StringValue) tester.evalAndGet("top");
+    assertEquals("ignore", topValue.getValue());
+    assertThat(tester.getDirtyValues()).containsExactly(new StringValue("leafy"));
+    assertThat(tester.getDeletedValues()).isEmpty();
+    tester.set(leaf, new StringValue("smushy"));
+    tester.invalidate();
+    topValue = (StringValue) tester.evalAndGet("top");
+    assertEquals("ignore", topValue.getValue());
+    assertThat(tester.getDirtyValues()).containsExactly(new StringValue("crunchy"));
+    assertThat(tester.getDeletedValues()).isEmpty();
+  }
+
+  private static final SkyFunction INTERRUPT_BUILDER = new SkyFunction() {
+
+    @Override
+    public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException,
+        InterruptedException {
+      throw new InterruptedException();
+    }
+
+    @Override
+    public String extractTag(SkyKey skyKey) {
+      throw new UnsupportedOperationException();
+    }
+  };
+
+  /**
+   * Utility function to induce a graph clean of whatever value is requested, by trying to build
+   * this value and interrupting the build as soon as this value's function evaluation starts.
+   */
+  private void failBuildAndRemoveValue(final SkyKey value) {
+    tester.set(value, null);
+    // Evaluator will think leaf was interrupted because it threw, so it will be cleaned from graph.
+    tester.getOrCreate(value, /*markAsModified=*/true).setBuilder(INTERRUPT_BUILDER);
+    tester.invalidate();
+    try {
+      tester.eval(/*keepGoing=*/false, value);
+      Assert.fail();
+    } catch (InterruptedException e) {
+      // Expected.
+    }
+    tester.getOrCreate(value, /*markAsModified=*/false).setBuilder(null);
+  }
+
+  /**
+   * Make sure that when a dirty value is building, the fact that a child may no longer exist in the
+   * graph doesn't cause problems.
+   */
+  @Test
+  public void dirtyBuildAfterFailedBuild() throws Exception {
+    initializeTester();
+    final SkyKey leaf = GraphTester.toSkyKey("leaf");
+    SkyKey top = GraphTester.toSkyKey("top");
+    tester.getOrCreate(top).addDependency(leaf).setComputedValue(COPY);
+    tester.set(leaf, new StringValue("leafy"));
+    StringValue topValue = (StringValue) tester.evalAndGet("top");
+    assertEquals("leafy", topValue.getValue());
+    assertThat(tester.getDirtyValues()).isEmpty();
+    assertThat(tester.getDeletedValues()).isEmpty();
+    failBuildAndRemoveValue(leaf);
+    // Leaf should no longer exist in the graph. Check that this doesn't cause problems.
+    tester.set(leaf, null);
+    tester.set(leaf, new StringValue("crunchy"));
+    tester.invalidate();
+    topValue = (StringValue) tester.evalAndGet("top");
+    assertEquals("crunchy", topValue.getValue());
+  }
+
+  /**
+   * Regression test: error when clearing reverse deps on dirty value about to be rebuilt, because
+   * child values were deleted and recreated in interim, forgetting they had reverse dep on dirty
+   * value in the first place.
+   */
+  @Test
+  public void changedBuildAfterFailedThenSuccessfulBuild() throws Exception {
+    initializeTester();
+    final SkyKey leaf = GraphTester.toSkyKey("leaf");
+    SkyKey top = GraphTester.toSkyKey("top");
+    tester.getOrCreate(top).addDependency(leaf).setComputedValue(COPY);
+    tester.set(leaf, new StringValue("leafy"));
+    StringValue topValue = (StringValue) tester.evalAndGet("top");
+    assertEquals("leafy", topValue.getValue());
+    assertThat(tester.getDirtyValues()).isEmpty();
+    assertThat(tester.getDeletedValues()).isEmpty();
+    failBuildAndRemoveValue(leaf);
+    tester.set(leaf, new StringValue("crunchy"));
+    tester.invalidate();
+    tester.eval(/*keepGoing=*/false, leaf);
+    // Leaf no longer has reverse dep on top. Check that this doesn't cause problems, even if the
+    // top value is evaluated unconditionally.
+    tester.getOrCreate(top, /*markAsModified=*/true);
+    tester.invalidate();
+    topValue = (StringValue) tester.evalAndGet("top");
+    assertEquals("crunchy", topValue.getValue());
+  }
+
+  /**
+   * Regression test: child value that has been deleted since it and its parent were marked dirty no
+   * longer knows it has a reverse dep on its parent.
+   *
+   * <p>Start with:
+   * <pre>
+   *              top0  ... top1000
+   *                  \  | /
+   *                   leaf
+   * </pre>
+   * Then fail to build leaf. Now the entry for leaf should have no "memory" that it was ever
+   * depended on by tops. Now build tops, but fail again.
+   */
+  @Test
+  public void manyDirtyValuesClearChildrenOnSecondFail() throws Exception {
+    final SkyKey leafKey = GraphTester.toSkyKey("leaf");
+    tester.set(leafKey, new StringValue("leafy"));
+    SkyKey lastKey = GraphTester.toSkyKey("last");
+    tester.set(lastKey, new StringValue("last"));
+    final List<SkyKey> tops = new ArrayList<>();
+    // Request far more top-level values than there are threads, so some of them will block until
+    // the leaf child is enqueued for processing.
+    for (int i = 0; i < 10000; i++) {
+      SkyKey topKey = GraphTester.toSkyKey("top" + i);
+      tester.getOrCreate(topKey).addDependency(leafKey).addDependency(lastKey)
+          .setComputedValue(CONCATENATE);
+      tops.add(topKey);
+    }
+    tester.eval(/*keepGoing=*/false, tops.toArray(new SkyKey[0]));
+    failBuildAndRemoveValue(leafKey);
+    // Request the tops. Since leaf was deleted from the graph last build, it no longer knows that
+    // its parents depend on it. When leaf throws, at least one of its parents (hopefully) will not
+    // have re-informed leaf that the parent depends on it, exposing the bug, since the parent
+    // should then not try to clean the reverse dep from leaf.
+    tester.set(leafKey, null);
+    // Evaluator will think leaf was interrupted because it threw, so it will be cleaned from graph.
+    tester.getOrCreate(leafKey, /*markAsModified=*/true).setBuilder(INTERRUPT_BUILDER);
+    tester.invalidate();
+    try {
+      tester.eval(/*keepGoing=*/false, tops.toArray(new SkyKey[0]));
+      Assert.fail();
+    } catch (InterruptedException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void failedDirtyBuild() throws Exception {
+    initializeTester();
+    SkyKey leaf = GraphTester.toSkyKey("leaf");
+    SkyKey top = GraphTester.toSkyKey("top");
+    tester.getOrCreate(top).addErrorDependency(leaf, new StringValue("recover"))
+        .setComputedValue(COPY);
+    tester.set(leaf, new StringValue("leafy"));
+    StringValue topValue = (StringValue) tester.evalAndGet("top");
+    assertEquals("leafy", topValue.getValue());
+    assertThat(tester.getDirtyValues()).isEmpty();
+    assertThat(tester.getDeletedValues()).isEmpty();
+    // Change leaf.
+    tester.getOrCreate(leaf, /*markAsModified=*/true).setHasError(true);
+    tester.getOrCreate(top, /*markAsModified=*/false).setHasError(true);
+    tester.invalidate();
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, top);
+    assertNull("value should not have completed evaluation", result.get(top));
+    assertWithMessage(
+        "The error thrown by leaf should have been swallowed by the error thrown by top")
+        .that(result.getError().getRootCauses()).containsExactly(top);
+  }
+
+  @Test
+  public void failedDirtyBuildInBuilder() throws Exception {
+    initializeTester();
+    SkyKey leaf = GraphTester.toSkyKey("leaf");
+    SkyKey secondError = GraphTester.toSkyKey("secondError");
+    SkyKey top = GraphTester.toSkyKey("top");
+    tester.getOrCreate(top).addDependency(leaf)
+        .addErrorDependency(secondError, new StringValue("recover")).setComputedValue(CONCATENATE);
+    tester.set(secondError, new StringValue("secondError")).addDependency(leaf);
+    tester.set(leaf, new StringValue("leafy"));
+    StringValue topValue = (StringValue) tester.evalAndGet("top");
+    assertEquals("leafysecondError", topValue.getValue());
+    assertThat(tester.getDirtyValues()).isEmpty();
+    assertThat(tester.getDeletedValues()).isEmpty();
+    // Invalidate leaf.
+    tester.getOrCreate(leaf, /*markAsModified=*/true);
+    tester.set(leaf, new StringValue("crunchy"));
+    tester.getOrCreate(secondError, /*markAsModified=*/true).setHasError(true);
+    tester.getOrCreate(top, /*markAsModified=*/false).setHasError(true);
+    tester.invalidate();
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, top);
+    assertNull("value should not have completed evaluation", result.get(top));
+    assertWithMessage(
+        "The error thrown by leaf should have been swallowed by the error thrown by top")
+        .that(result.getError().getRootCauses()).containsExactly(top);
+  }
+
+  @Test
+  public void dirtyErrorTransienceValue() throws Exception {
+    initializeTester();
+    SkyKey error = GraphTester.toSkyKey("error");
+    tester.getOrCreate(error).setHasError(true);
+    assertNotNull(tester.evalAndGetError(error));
+    tester.invalidateTransientErrors();
+    SkyKey secondError = GraphTester.toSkyKey("secondError");
+    tester.getOrCreate(secondError).setHasError(true);
+    // secondError declares a new dependence on ErrorTransienceValue, but not until it has already
+    // thrown an error.
+    assertNotNull(tester.evalAndGetError(secondError));
+  }
+
+  @Test
+  public void dirtyDependsOnErrorTurningGood() throws Exception {
+    initializeTester();
+    SkyKey error = GraphTester.toSkyKey("error");
+    tester.getOrCreate(error).setHasError(true);
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.getOrCreate(topKey).addDependency(error).setComputedValue(COPY);
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, topKey);
+    assertThat(result.getError(topKey).getRootCauses()).containsExactly(error);
+    tester.getOrCreate(error).setHasError(false);
+    StringValue val = new StringValue("reformed");
+    tester.set(error, val);
+    tester.invalidate();
+    result = tester.eval(/*keepGoing=*/false, topKey);
+    assertEquals(val, result.get(topKey));
+    assertFalse(result.hasError());
+  }
+
+  /** Regression test for crash bug. */
+  @Test
+  public void dirtyWithOwnErrorDependsOnTransientErrorTurningGood() throws Exception {
+    initializeTester();
+    final SkyKey error = GraphTester.toSkyKey("error");
+    tester.getOrCreate(error).setHasTransientError(true);
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    SkyFunction errorFunction = new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws GenericFunctionException,
+          InterruptedException {
+        try {
+          return env.getValueOrThrow(error, SomeErrorException.class);
+        } catch (SomeErrorException e) {
+          throw new GenericFunctionException(e, Transience.PERSISTENT);
+        }
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        throw new UnsupportedOperationException();
+      }
+    };
+    tester.getOrCreate(topKey).setBuilder(errorFunction);
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, topKey);
+    tester.invalidateTransientErrors();
+    assertThat(result.getError(topKey).getRootCauses()).containsExactly(topKey);
+    tester.getOrCreate(error).setHasTransientError(false);
+    StringValue reformed = new StringValue("reformed");
+    tester.set(error, reformed);
+    tester.getOrCreate(topKey).setBuilder(null).addDependency(error).setComputedValue(COPY);
+    tester.invalidate();
+    tester.invalidateTransientErrors();
+    result = tester.eval(/*keepGoing=*/false, topKey);
+    assertEquals(reformed, result.get(topKey));
+    assertFalse(result.hasError());
+  }
+
+  /**
+   * Make sure that when an error is thrown, it is given for handling only to parents that have
+   * already registered a dependence on the value that threw the error.
+   *
+   * <pre>
+   *  topBubbleKey  topErrorFirstKey
+   *    |       \    /
+   *  midKey  errorKey
+   *    |
+   * slowKey
+   * </pre>
+   *
+   * On the second build, errorKey throws, and the threadpool aborts before midKey finishes.
+   * topBubbleKey therefore has not yet requested errorKey this build. If errorKey bubbles up to it,
+   * topBubbleKey must be able to handle that. (The evaluator can deal with this either by not
+   * allowing errorKey to bubble up to topBubbleKey, or by dealing with that case.)
+   */
+  @Test
+  public void errorOnlyBubblesToRequestingParents() throws Exception {
+    // We need control over the order of reverse deps, so use a deterministic graph.
+    setGraphForTesting(new DeterministicInMemoryGraph());
+    SkyKey errorKey = GraphTester.toSkyKey("error");
+    tester.set(errorKey, new StringValue("biding time"));
+    SkyKey slowKey = GraphTester.toSkyKey("slow");
+    tester.set(slowKey, new StringValue("slow"));
+    SkyKey midKey = GraphTester.toSkyKey("mid");
+    tester.getOrCreate(midKey).addDependency(slowKey).setComputedValue(COPY);
+    SkyKey topErrorFirstKey = GraphTester.toSkyKey("2nd top alphabetically");
+    tester.getOrCreate(topErrorFirstKey).addDependency(errorKey).setComputedValue(CONCATENATE);
+    SkyKey topBubbleKey = GraphTester.toSkyKey("1st top alphabetically");
+    tester.getOrCreate(topBubbleKey).addDependency(midKey).addDependency(errorKey)
+        .setComputedValue(CONCATENATE);
+    // First error-free evaluation, to put all values in graph.
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false,
+        topErrorFirstKey, topBubbleKey);
+    assertEquals("biding time", result.get(topErrorFirstKey).getValue());
+    assertEquals("slowbiding time", result.get(topBubbleKey).getValue());
+    // Set up timing of child values: slowKey waits to finish until errorKey has thrown an
+    // exception that has been caught by the threadpool.
+    tester.set(slowKey, null);
+    CountDownLatch errorFinish = new CountDownLatch(1);
+    tester.set(errorKey, null);
+    tester.getOrCreate(errorKey).setBuilder(
+        new ChainedFunction(/*notifyStart=*/null, /*waitToFinish=*/null,
+            /*notifyFinish=*/errorFinish, /*waitForException=*/false, /*value=*/null,
+            /*deps=*/ImmutableList.<SkyKey>of()));
+    tester.getOrCreate(slowKey).setBuilder(
+        new ChainedFunction(/*notifyStart=*/null, /*waitToFinish=*/errorFinish,
+            /*notifyFinish=*/null, /*waitForException=*/true, new StringValue("leaf2"),
+            /*deps=*/ImmutableList.<SkyKey>of()));
+    tester.invalidate();
+    // errorKey finishes, written to graph -> slowKey maybe starts+finishes & (Visitor aborts)
+    // -> some top key builds.
+    result = tester.eval(/*keepGoing=*/false, topErrorFirstKey, topBubbleKey);
+    assertTrue(result.hasError());
+    assertNotNull(result.getError(topErrorFirstKey));
+  }
+
+  @Test
+  public void dirtyWithRecoveryErrorDependsOnErrorTurningGood() throws Exception {
+    initializeTester();
+    final SkyKey error = GraphTester.toSkyKey("error");
+    tester.getOrCreate(error).setHasError(true);
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    SkyFunction recoveryErrorFunction = new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException,
+          InterruptedException {
+        try {
+          env.getValueOrThrow(error, SomeErrorException.class);
+        } catch (SomeErrorException e) {
+          throw new GenericFunctionException(e, Transience.PERSISTENT);
+        }
+        return null;
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        throw new UnsupportedOperationException();
+      }
+    };
+    tester.getOrCreate(topKey).setBuilder(recoveryErrorFunction);
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, topKey);
+    assertThat(result.getError(topKey).getRootCauses()).containsExactly(topKey);
+    tester.getOrCreate(error).setHasError(false);
+    StringValue reformed = new StringValue("reformed");
+    tester.set(error, reformed);
+    tester.getOrCreate(topKey).setBuilder(null).addDependency(error).setComputedValue(COPY);
+    tester.invalidate();
+    result = tester.eval(/*keepGoing=*/false, topKey);
+    assertEquals(reformed, result.get(topKey));
+    assertFalse(result.hasError());
+  }
+
+
+  @Test
+  public void absentParent() throws Exception {
+    initializeTester();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    tester.set(errorKey, new StringValue("biding time"));
+    SkyKey absentParentKey = GraphTester.toSkyKey("absentParent");
+    tester.getOrCreate(absentParentKey).addDependency(errorKey).setComputedValue(CONCATENATE);
+    assertEquals(new StringValue("biding time"),
+        tester.evalAndGet(/*keepGoing=*/false, absentParentKey));
+    tester.getOrCreate(errorKey, /*markAsModified=*/true).setHasError(true);
+    SkyKey newParent = GraphTester.toSkyKey("newParent");
+    tester.getOrCreate(newParent).addDependency(errorKey).setComputedValue(CONCATENATE);
+    tester.invalidate();
+    EvaluationResult<StringValue> result = tester.eval(/*keepGoing=*/false, newParent);
+    ErrorInfo error = result.getError(newParent);
+    assertThat(error.getRootCauses()).containsExactly(errorKey);
+  }
+
+  // Tests that we have a sane implementation of error transience.
+  @Test
+  public void errorTransienceBug() throws Exception {
+    tester.getOrCreate("key").setHasTransientError(true);
+    assertNotNull(tester.evalAndGetError("key").getException());
+    StringValue value = new StringValue("hi");
+    tester.getOrCreate("key").setHasTransientError(false).setConstantValue(value);
+    tester.invalidateTransientErrors();
+    assertEquals(value, tester.evalAndGet("key"));
+    // This works because the version of the ValueEntry for the ErrorTransience value is always
+    // increased on each InMemoryMemoizingEvaluator#evaluate call. But that's not the only way to
+    // implement error transience; another valid implementation would be to unconditionally mark
+    // values depending on the ErrorTransience value as being changed (rather than merely dirtied)
+    // during invalidation.
+  }
+
+  @Test
+  public void transientErrorTurningGoodHasNoError() throws Exception {
+    initializeTester();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    tester.getOrCreate(errorKey).setHasTransientError(true);
+    ErrorInfo errorInfo = tester.evalAndGetError(errorKey);
+    assertNotNull(errorInfo);
+    assertThat(errorInfo.getRootCauses()).containsExactly(errorKey);
+    // Re-evaluates to same thing when errors are invalidated
+    tester.invalidateTransientErrors();
+    errorInfo = tester.evalAndGetError(errorKey);
+    assertNotNull(errorInfo);
+    StringValue value = new StringValue("reformed");
+    assertThat(errorInfo.getRootCauses()).containsExactly(errorKey);
+    tester.getOrCreate(errorKey, /*markAsModified=*/false).setHasTransientError(false)
+        .setConstantValue(value);
+    tester.invalidateTransientErrors();
+    StringValue stringValue = (StringValue) tester.evalAndGet(/*keepGoing=*/true, errorKey);
+    assertSame(stringValue, value);
+    // Value builder will now throw, but we should never get to it because it isn't dirty.
+    tester.getOrCreate(errorKey, /*markAsModified=*/false).setHasTransientError(true);
+    tester.invalidateTransientErrors();
+    stringValue = (StringValue) tester.evalAndGet(/*keepGoing=*/true, errorKey);
+    assertSame(stringValue, value);
+  }
+
+  @Test
+  public void deleteInvalidatedValue() throws Exception {
+    initializeTester();
+    SkyKey top = GraphTester.toSkyKey("top");
+    SkyKey toDelete = GraphTester.toSkyKey("toDelete");
+    // Must be a concatenation -- COPY doesn't actually copy.
+    tester.getOrCreate(top).addDependency(toDelete).setComputedValue(CONCATENATE);
+    tester.set(toDelete, new StringValue("toDelete"));
+    SkyValue value = tester.evalAndGet("top");
+    SkyKey forceInvalidation = GraphTester.toSkyKey("forceInvalidation");
+    tester.set(forceInvalidation, new StringValue("forceInvalidation"));
+    tester.getOrCreate(toDelete, /*markAsModified=*/true);
+    tester.invalidate();
+    tester.eval(/*keepGoing=*/false, forceInvalidation);
+    tester.delete("toDelete");
+    WeakReference<SkyValue> ref = new WeakReference<>(value);
+    value = null;
+    tester.eval(/*keepGoing=*/false, forceInvalidation);
+    tester.invalidate(); // So that invalidation receiver doesn't hang on to reference.
+    GcFinalization.awaitClear(ref);
+  }
+
+  /**
+   * General stress/fuzz test of the evaluator with failure. Construct a large graph, and then throw
+   * exceptions during building at various points.
+   */
+  @Test
+  public void twoRailLeftRightDependenciesWithFailure() throws Exception {
+    initializeTester();
+    SkyKey[] leftValues = new SkyKey[TEST_NODE_COUNT];
+    SkyKey[] rightValues = new SkyKey[TEST_NODE_COUNT];
+    for (int i = 0; i < TEST_NODE_COUNT; i++) {
+      leftValues[i] = GraphTester.toSkyKey("left-" + i);
+      rightValues[i] = GraphTester.toSkyKey("right-" + i);
+      if (i == 0) {
+        tester.getOrCreate(leftValues[i])
+              .addDependency("leaf")
+              .setComputedValue(COPY);
+        tester.getOrCreate(rightValues[i])
+              .addDependency("leaf")
+              .setComputedValue(COPY);
+      } else {
+        tester.getOrCreate(leftValues[i])
+              .addDependency(leftValues[i - 1])
+              .addDependency(rightValues[i - 1])
+              .setComputedValue(new PassThroughSelected(leftValues[i - 1]));
+        tester.getOrCreate(rightValues[i])
+              .addDependency(leftValues[i - 1])
+              .addDependency(rightValues[i - 1])
+              .setComputedValue(new PassThroughSelected(rightValues[i - 1]));
+      }
+    }
+    tester.set("leaf", new StringValue("leaf"));
+
+    String lastLeft = "left-" + (TEST_NODE_COUNT - 1);
+    String lastRight = "right-" + (TEST_NODE_COUNT - 1);
+
+    for (int i = 0; i < TESTED_NODES; i++) {
+      try {
+        tester.getOrCreate(leftValues[i], /*markAsModified=*/true).setHasError(true);
+        tester.invalidate();
+        EvaluationResult<StringValue> result = tester.eval(
+            /*keep_going=*/false, lastLeft, lastRight);
+        assertTrue(result.hasError());
+        tester.differencer.invalidate(ImmutableList.of(leftValues[i]));
+        tester.invalidate();
+        result = tester.eval(/*keep_going=*/false, lastLeft, lastRight);
+        assertTrue(result.hasError());
+        tester.getOrCreate(leftValues[i], /*markAsModified=*/true).setHasError(false);
+        tester.invalidate();
+        result = tester.eval(/*keep_going=*/false, lastLeft, lastRight);
+        assertEquals(new StringValue("leaf"), result.get(toSkyKey(lastLeft)));
+        assertEquals(new StringValue("leaf"), result.get(toSkyKey(lastRight)));
+      } catch (Exception e) {
+        System.err.println("twoRailLeftRightDependenciesWithFailure exception on run " + i);
+        throw e;
+      }
+    }
+  }
+
+  @Test
+  public void valueInjection() throws Exception {
+    SkyKey key = GraphTester.toSkyKey("new_value");
+    SkyValue val = new StringValue("val");
+
+    tester.differencer.inject(ImmutableMap.of(key, val));
+    assertEquals(val, tester.evalAndGet("new_value"));
+  }
+
+  @Test
+  public void valueInjectionOverExistingEntry() throws Exception {
+    SkyKey key = GraphTester.toSkyKey("value");
+    SkyValue val = new StringValue("val");
+
+    tester.getOrCreate(key).setConstantValue(new StringValue("old_val"));
+    tester.differencer.inject(ImmutableMap.of(key, val));
+    assertEquals(val, tester.evalAndGet("value"));
+  }
+
+  @Test
+  public void valueInjectionOverExistingDirtyEntry() throws Exception {
+    SkyKey key = GraphTester.toSkyKey("value");
+    SkyValue val = new StringValue("val");
+
+    tester.getOrCreate(key).setConstantValue(new StringValue("old_val"));
+    tester.differencer.inject(ImmutableMap.of(key, val));
+    tester.eval(/*keepGoing=*/false, new SkyKey[0]); // Create the value.
+
+    tester.differencer.invalidate(ImmutableList.of(key));
+    tester.eval(/*keepGoing=*/false, new SkyKey[0]); // Mark value as dirty.
+
+    tester.differencer.inject(ImmutableMap.of(key, val));
+    tester.eval(/*keepGoing=*/false, new SkyKey[0]); // Inject again.
+    assertEquals(val, tester.evalAndGet("value"));
+  }
+
+  @Test
+  public void valueInjectionOverExistingEntryMarkedForInvalidation() throws Exception {
+    SkyKey key = GraphTester.toSkyKey("value");
+    SkyValue val = new StringValue("val");
+
+    tester.getOrCreate(key).setConstantValue(new StringValue("old_val"));
+    tester.differencer.invalidate(ImmutableList.of(key));
+    tester.differencer.inject(ImmutableMap.of(key, val));
+    assertEquals(val, tester.evalAndGet("value"));
+  }
+
+  @Test
+  public void valueInjectionOverExistingEntryMarkedForDeletion() throws Exception {
+    SkyKey key = GraphTester.toSkyKey("value");
+    SkyValue val = new StringValue("val");
+
+    tester.getOrCreate(key).setConstantValue(new StringValue("old_val"));
+    tester.graph.delete(Predicates.<SkyKey>alwaysTrue());
+    tester.differencer.inject(ImmutableMap.of(key, val));
+    assertEquals(val, tester.evalAndGet("value"));
+  }
+
+  @Test
+  public void valueInjectionOverExistingEqualEntryMarkedForInvalidation() throws Exception {
+    SkyKey key = GraphTester.toSkyKey("value");
+    SkyValue val = new StringValue("val");
+
+    tester.differencer.inject(ImmutableMap.of(key, val));
+    assertEquals(val, tester.evalAndGet("value"));
+
+    tester.differencer.invalidate(ImmutableList.of(key));
+    tester.differencer.inject(ImmutableMap.of(key, val));
+    assertEquals(val, tester.evalAndGet("value"));
+  }
+
+  @Test
+  public void valueInjectionOverExistingEqualEntryMarkedForDeletion() throws Exception {
+    SkyKey key = GraphTester.toSkyKey("value");
+    SkyValue val = new StringValue("val");
+
+    tester.differencer.inject(ImmutableMap.of(key, val));
+    assertEquals(val, tester.evalAndGet("value"));
+
+    tester.graph.delete(Predicates.<SkyKey>alwaysTrue());
+    tester.differencer.inject(ImmutableMap.of(key, val));
+    assertEquals(val, tester.evalAndGet("value"));
+  }
+
+  @Test
+  public void valueInjectionOverValueWithDeps() throws Exception {
+    SkyKey key = GraphTester.toSkyKey("value");
+    SkyValue val = new StringValue("val");
+    StringValue prevVal = new StringValue("foo");
+
+    tester.getOrCreate("other").setConstantValue(prevVal);
+    tester.getOrCreate(key).addDependency("other").setComputedValue(COPY);
+    assertEquals(prevVal, tester.evalAndGet("value"));
+    tester.differencer.inject(ImmutableMap.of(key, val));
+    try {
+      tester.evalAndGet("value");
+      Assert.fail("injection over value with deps should have failed");
+    } catch (IllegalStateException e) {
+      assertEquals("existing entry for Type:value has deps: [Type:other]", e.getMessage());
+    }
+  }
+
+  @Test
+  public void valueInjectionOverEqualValueWithDeps() throws Exception {
+    SkyKey key = GraphTester.toSkyKey("value");
+    SkyValue val = new StringValue("val");
+
+    tester.getOrCreate("other").setConstantValue(val);
+    tester.getOrCreate(key).addDependency("other").setComputedValue(COPY);
+    assertEquals(val, tester.evalAndGet("value"));
+    tester.differencer.inject(ImmutableMap.of(key, val));
+    try {
+      tester.evalAndGet("value");
+      Assert.fail("injection over value with deps should have failed");
+    } catch (IllegalStateException e) {
+      assertEquals("existing entry for Type:value has deps: [Type:other]", e.getMessage());
+    }
+  }
+
+  @Test
+  public void valueInjectionOverValueWithErrors() throws Exception {
+    SkyKey key = GraphTester.toSkyKey("value");
+    SkyValue val = new StringValue("val");
+
+    tester.getOrCreate(key).setHasError(true);
+    tester.evalAndGetError(key);
+
+    tester.differencer.inject(ImmutableMap.of(key, val));
+    assertEquals(val, tester.evalAndGet(false, key));
+  }
+
+  @Test
+  public void valueInjectionInvalidatesReverseDeps() throws Exception {
+    SkyKey childKey = GraphTester.toSkyKey("child");
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    StringValue oldVal = new StringValue("old_val");
+
+    tester.getOrCreate(childKey).setConstantValue(oldVal);
+    tester.getOrCreate(parentKey).addDependency("child").setComputedValue(COPY);
+
+    EvaluationResult<SkyValue> result = tester.eval(false, parentKey);
+    assertFalse(result.hasError());
+    assertEquals(oldVal, result.get(parentKey));
+
+    SkyValue val = new StringValue("val");
+    tester.differencer.inject(ImmutableMap.of(childKey, val));
+    assertEquals(val, tester.evalAndGet("child"));
+    // Injecting a new child should have invalidated the parent.
+    Assert.assertNull(tester.getExistingValue("parent"));
+
+    tester.eval(false, childKey);
+    assertEquals(val, tester.getExistingValue("child"));
+    Assert.assertNull(tester.getExistingValue("parent"));
+    assertEquals(val, tester.evalAndGet("parent"));
+  }
+
+  @Test
+  public void valueInjectionOverExistingEqualEntryDoesNotInvalidate() throws Exception {
+    SkyKey childKey = GraphTester.toSkyKey("child");
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    SkyValue val = new StringValue("same_val");
+
+    tester.getOrCreate(parentKey).addDependency("child").setComputedValue(COPY);
+    tester.getOrCreate(childKey).setConstantValue(new StringValue("same_val"));
+    assertEquals(val, tester.evalAndGet("parent"));
+
+    tester.differencer.inject(ImmutableMap.of(childKey, val));
+    assertEquals(val, tester.getExistingValue("child"));
+    // Since we are injecting an equal value, the parent should not have been invalidated.
+    assertEquals(val, tester.getExistingValue("parent"));
+  }
+
+  @Test
+  public void valueInjectionInterrupt() throws Exception {
+    SkyKey key = GraphTester.toSkyKey("key");
+    SkyValue val = new StringValue("val");
+
+    tester.differencer.inject(ImmutableMap.of(key, val));
+    Thread.currentThread().interrupt();
+    try {
+      tester.evalAndGet("key");
+      fail();
+    } catch (InterruptedException expected) {
+      // Expected.
+    }
+    SkyValue newVal = tester.evalAndGet("key");
+    assertEquals(val, newVal);
+  }
+
+  @Test
+  public void persistentErrorsNotRerun() throws Exception {
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    SkyKey transientErrorKey = GraphTester.toSkyKey("transientError");
+    SkyKey persistentErrorKey1 = GraphTester.toSkyKey("persistentError1");
+    SkyKey persistentErrorKey2 = GraphTester.toSkyKey("persistentError2");
+
+    tester.getOrCreate(topKey)
+          .addErrorDependency(transientErrorKey, new StringValue("doesn't matter"))
+          .addErrorDependency(persistentErrorKey1, new StringValue("doesn't matter"))
+          .setHasError(true);
+    tester.getOrCreate(persistentErrorKey1).setHasError(true);
+    tester.getOrCreate(transientErrorKey)
+          .addErrorDependency(persistentErrorKey2, new StringValue("doesn't matter"))
+          .setHasTransientError(true);
+    tester.getOrCreate(persistentErrorKey2).setHasError(true);
+
+    tester.evalAndGetError(topKey);
+    assertThat(tester.getEnqueuedValues()).containsExactly(
+        topKey, transientErrorKey, persistentErrorKey1, persistentErrorKey2);
+
+    tester.invalidate();
+    tester.invalidateTransientErrors();
+    tester.evalAndGetError(topKey);
+    // TODO(bazel-team): We can do better here once we implement change pruning for errors.
+    assertThat(tester.getEnqueuedValues()).containsExactly(topKey, transientErrorKey);
+  }
+
+  @Test
+  public void cachedChildErrorDepWithSiblingDepOnNoKeepGoingEval() throws Exception {
+    SkyKey parent1Key = GraphTester.toSkyKey("parent1");
+    SkyKey parent2Key = GraphTester.toSkyKey("parent2");
+    final SkyKey errorKey = GraphTester.toSkyKey("error");
+    final SkyKey otherKey = GraphTester.toSkyKey("other");
+    SkyFunction parentBuilder = new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) {
+        env.getValue(errorKey);
+        env.getValue(otherKey);
+        if (env.valuesMissing()) {
+          return null;
+        }
+        return new StringValue("parent");
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        return null;
+      }
+    };
+    tester.getOrCreate(parent1Key).setBuilder(parentBuilder);
+    tester.getOrCreate(parent2Key).setBuilder(parentBuilder);
+    tester.getOrCreate(errorKey).setConstantValue(new StringValue("no error yet"));
+    tester.getOrCreate(otherKey).setConstantValue(new StringValue("other"));
+    tester.eval(/*keepGoing=*/true, parent1Key);
+    tester.eval(/*keepGoing=*/false, parent2Key);
+    tester.getOrCreate(errorKey, /*markAsModified=*/true).setHasError(true);
+    tester.invalidate();
+    tester.eval(/*keepGoing=*/true, parent1Key);
+    tester.eval(/*keepGoing=*/false, parent2Key);
+  }
+
+  private void setGraphForTesting(NotifyingInMemoryGraph notifyingInMemoryGraph) {
+    InMemoryMemoizingEvaluator memoizingEvaluator = (InMemoryMemoizingEvaluator) tester.graph;
+    memoizingEvaluator.setGraphForTesting(notifyingInMemoryGraph);
+  }
+
+  private static final class PassThroughSelected implements ValueComputer {
+    private final SkyKey key;
+
+    public PassThroughSelected(SkyKey key) {
+      this.key = key;
+    }
+
+    @Override
+    public SkyValue compute(Map<SkyKey, SkyValue> deps, SkyFunction.Environment env) {
+      return Preconditions.checkNotNull(deps.get(key));
+    }
+  }
+
+  /**
+   * A graph tester that is specific to the memoizing evaluator, with some convenience methods.
+   */
+  private class MemoizingEvaluatorTester extends GraphTester {
+    private RecordingDifferencer differencer;
+    private MemoizingEvaluator graph;
+    private SequentialBuildDriver driver;
+    private TrackingInvalidationReceiver invalidationReceiver = new TrackingInvalidationReceiver();
+
+    public void initialize() {
+      this.differencer = new RecordingDifferencer();
+      this.graph = new InMemoryMemoizingEvaluator(
+          ImmutableMap.of(NODE_TYPE, createDelegatingFunction()), differencer,
+          invalidationReceiver, emittedEventState, true);
+      this.driver = new SequentialBuildDriver(graph);
+    }
+
+    public void setInvalidationReceiver(TrackingInvalidationReceiver customInvalidationReceiver) {
+      Preconditions.checkState(graph == null, "graph already initialized");
+      invalidationReceiver = customInvalidationReceiver;
+    }
+
+    public void invalidate() {
+      differencer.invalidate(getModifiedValues());
+      getModifiedValues().clear();
+      invalidationReceiver.clear();
+    }
+
+    public void invalidateTransientErrors() {
+      differencer.invalidateTransientErrors();
+    }
+
+    public void delete(String key) {
+      graph.delete(Predicates.equalTo(GraphTester.skyKey(key)));
+    }
+
+    public void resetPlayedEvents() {
+      emittedEventState.clear();
+    }
+
+    public Set<SkyValue> getDirtyValues() {
+      return invalidationReceiver.dirty;
+    }
+
+    public Set<SkyValue> getDeletedValues() {
+      return invalidationReceiver.deleted;
+    }
+
+    public Set<SkyKey> getEnqueuedValues() {
+      return invalidationReceiver.enqueued;
+    }
+
+    public <T extends SkyValue> EvaluationResult<T> eval(
+        boolean keepGoing, int numThreads, SkyKey... keys) throws InterruptedException {
+      assertThat(getModifiedValues()).isEmpty();
+      return driver.evaluate(ImmutableList.copyOf(keys), keepGoing, numThreads, reporter);
+    }
+
+    public <T extends SkyValue> EvaluationResult<T> eval(boolean keepGoing, SkyKey... keys)
+        throws InterruptedException {
+      return eval(keepGoing, 100, keys);
+    }
+
+    public <T extends SkyValue> EvaluationResult<T> eval(boolean keepGoing, String... keys)
+        throws InterruptedException {
+      return eval(keepGoing, toSkyKeys(keys));
+    }
+
+    public SkyValue evalAndGet(boolean keepGoing, String key)
+        throws InterruptedException {
+      return evalAndGet(keepGoing, new SkyKey(NODE_TYPE, key));
+    }
+
+    public SkyValue evalAndGet(String key) throws InterruptedException {
+      return evalAndGet(/*keepGoing=*/false, key);
+    }
+
+    public SkyValue evalAndGet(boolean keepGoing, SkyKey key)
+        throws InterruptedException {
+      EvaluationResult<StringValue> evaluationResult = eval(keepGoing, key);
+      SkyValue result = evaluationResult.get(key);
+      assertNotNull(evaluationResult.toString(), result);
+      return result;
+    }
+
+    public ErrorInfo evalAndGetError(SkyKey key) throws InterruptedException {
+      EvaluationResult<StringValue> evaluationResult = eval(/*keepGoing=*/true, key);
+      ErrorInfo result = evaluationResult.getError(key);
+      assertNotNull(evaluationResult.toString(), result);
+      return result;
+    }
+
+    public ErrorInfo evalAndGetError(String key) throws InterruptedException {
+      return evalAndGetError(new SkyKey(NODE_TYPE, key));
+    }
+
+    @Nullable
+    public SkyValue getExistingValue(String key) {
+      return graph.getExistingValueForTesting(new SkyKey(NODE_TYPE, key));
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/skyframe/NodeEntryTest.java b/src/test/java/com/google/devtools/build/skyframe/NodeEntryTest.java
new file mode 100644
index 0000000..378b097
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skyframe/NodeEntryTest.java
@@ -0,0 +1,668 @@
+// Copyright 2014 Google Inc. 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.skyframe;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.devtools.build.lib.collect.nestedset.NestedSet;
+import com.google.devtools.build.lib.collect.nestedset.NestedSetBuilder;
+import com.google.devtools.build.lib.collect.nestedset.Order;
+import com.google.devtools.build.lib.util.GroupedList.GroupedListHelper;
+import com.google.devtools.build.skyframe.NodeEntry.DependencyState;
+import com.google.devtools.build.skyframe.SkyFunctionException.ReifiedSkyFunctionException;
+import com.google.devtools.build.skyframe.SkyFunctionException.Transience;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import javax.annotation.Nullable;
+
+/**
+ * Tests for {@link NodeEntry}.
+ */
+@RunWith(JUnit4.class)
+public class NodeEntryTest {
+
+  private static final SkyFunctionName NODE_TYPE = new SkyFunctionName("Type", false);
+  private static final NestedSet<TaggedEvents> NO_EVENTS =
+      NestedSetBuilder.<TaggedEvents>emptySet(Order.STABLE_ORDER);
+
+  private static SkyKey key(String name) {
+    return new SkyKey(NODE_TYPE, name);
+  }
+
+  @Test
+  public void createEntry() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    assertFalse(entry.isDone());
+    assertTrue(entry.isReady());
+    assertFalse(entry.isDirty());
+    assertFalse(entry.isChanged());
+    assertThat(entry.getTemporaryDirectDeps()).isEmpty();
+  }
+
+  @Test
+  public void signalEntry() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    SkyKey dep1 = key("dep1");
+    addTemporaryDirectDep(entry, dep1);
+    assertFalse(entry.isReady());
+    assertTrue(entry.signalDep());
+    assertTrue(entry.isReady());
+    assertThat(entry.getTemporaryDirectDeps()).containsExactly(dep1);
+    SkyKey dep2 = key("dep2");
+    SkyKey dep3 = key("dep3");
+    addTemporaryDirectDep(entry, dep2);
+    addTemporaryDirectDep(entry, dep3);
+    assertFalse(entry.isReady());
+    assertFalse(entry.signalDep());
+    assertFalse(entry.isReady());
+    assertTrue(entry.signalDep());
+    assertTrue(entry.isReady());
+    assertThat(setValue(entry, new SkyValue() {},
+        /*errorInfo=*/null, /*graphVersion=*/0L)).isEmpty();
+    assertTrue(entry.isDone());
+    assertEquals(new IntVersion(0L), entry.getVersion());
+    assertThat(entry.getDirectDeps()).containsExactly(dep1, dep2, dep3);
+  }
+
+  @Test
+  public void reverseDeps() {
+    NodeEntry entry = new NodeEntry();
+    SkyKey mother = key("mother");
+    SkyKey father = key("father");
+    assertEquals(DependencyState.NEEDS_SCHEDULING, entry.addReverseDepAndCheckIfDone(mother));
+    assertEquals(DependencyState.ADDED_DEP, entry.addReverseDepAndCheckIfDone(null));
+    assertEquals(DependencyState.ADDED_DEP, entry.addReverseDepAndCheckIfDone(father));
+    assertThat(setValue(entry, new SkyValue() {},
+        /*errorInfo=*/null, /*graphVersion=*/0L)).containsExactly(mother, father);
+    assertThat(entry.getReverseDeps()).containsExactly(mother, father);
+    assertTrue(entry.isDone());
+    entry.removeReverseDep(mother);
+    assertFalse(Iterables.contains(entry.getReverseDeps(), mother));
+  }
+
+  @Test
+  public void errorValue() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    ReifiedSkyFunctionException exception = new ReifiedSkyFunctionException(
+        new GenericFunctionException(new SomeErrorException("oops"), Transience.PERSISTENT),
+        key("cause"));
+    ErrorInfo errorInfo = new ErrorInfo(exception);
+    assertThat(setValue(entry, /*value=*/null, errorInfo, /*graphVersion=*/0L)).isEmpty();
+    assertTrue(entry.isDone());
+    assertNull(entry.getValue());
+    assertEquals(errorInfo, entry.getErrorInfo());
+  }
+
+  @Test
+  public void errorAndValue() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    ReifiedSkyFunctionException exception = new ReifiedSkyFunctionException(
+        new GenericFunctionException(new SomeErrorException("oops"), Transience.PERSISTENT),
+        key("cause"));
+    ErrorInfo errorInfo = new ErrorInfo(exception);
+    setValue(entry, new SkyValue() {}, errorInfo, /*graphVersion=*/0L);
+    assertTrue(entry.isDone());
+    assertEquals(errorInfo, entry.getErrorInfo());
+  }
+
+  @Test
+  public void crashOnNullErrorAndValue() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    try {
+      setValue(entry, /*value=*/null, /*errorInfo=*/null, /*graphVersion=*/0L);
+      fail();
+    } catch (IllegalStateException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void crashOnTooManySignals() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    try {
+      entry.signalDep();
+      fail();
+    } catch (IllegalStateException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void crashOnDifferentValue() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    setValue(entry, new SkyValue() {}, /*errorInfo=*/null, /*graphVersion=*/0L);
+    try {
+      // Value() {} and Value() {} are not .equals().
+      setValue(entry, new SkyValue() {}, /*errorInfo=*/null, /*graphVersion=*/1L);
+      fail();
+    } catch (IllegalStateException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void dirtyLifecycle() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    SkyKey dep = key("dep");
+    addTemporaryDirectDep(entry, dep);
+    entry.signalDep();
+    setValue(entry, new SkyValue() {}, /*errorInfo=*/null, /*graphVersion=*/0L);
+    assertFalse(entry.isDirty());
+    assertTrue(entry.isDone());
+    entry.markDirty(/*isChanged=*/false);
+    assertTrue(entry.isDirty());
+    assertFalse(entry.isChanged());
+    assertFalse(entry.isDone());
+    assertTrue(entry.isReady());
+    assertThat(entry.getTemporaryDirectDeps()).isEmpty();
+    SkyKey parent = key("parent");
+    entry.addReverseDepAndCheckIfDone(parent);
+    assertEquals(BuildingState.DirtyState.CHECK_DEPENDENCIES, entry.getDirtyState());
+    assertThat(entry.getNextDirtyDirectDeps()).containsExactly(dep).inOrder();
+    addTemporaryDirectDep(entry, dep);
+    entry.signalDep();
+    assertThat(entry.getTemporaryDirectDeps()).containsExactly(dep);
+    assertTrue(entry.isReady());
+    assertThat(setValue(entry, new SkyValue() {}, /*errorInfo=*/null,
+        /*graphVersion=*/1L)).containsExactly(parent);
+  }
+
+  @Test
+  public void changedLifecycle() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    SkyKey dep = key("dep");
+    addTemporaryDirectDep(entry, dep);
+    entry.signalDep();
+    setValue(entry, new SkyValue() {}, /*errorInfo=*/null, /*graphVersion=*/0L);
+    assertFalse(entry.isDirty());
+    assertTrue(entry.isDone());
+    entry.markDirty(/*isChanged=*/true);
+    assertTrue(entry.isDirty());
+    assertTrue(entry.isChanged());
+    assertFalse(entry.isDone());
+    assertTrue(entry.isReady());
+    SkyKey parent = key("parent");
+    entry.addReverseDepAndCheckIfDone(parent);
+    assertEquals(BuildingState.DirtyState.REBUILDING, entry.getDirtyState());
+    assertTrue(entry.isReady());
+    assertThat(entry.getTemporaryDirectDeps()).isEmpty();
+    assertThat(setValue(entry, new SkyValue() {}, /*errorInfo=*/null,
+        /*graphVersion=*/1L)).containsExactly(parent);
+    assertEquals(new IntVersion(1L), entry.getVersion());
+  }
+
+  @Test
+  public void markDirtyThenChanged() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    addTemporaryDirectDep(entry, key("dep"));
+    entry.signalDep();
+    setValue(entry, new SkyValue() {}, /*errorInfo=*/null, /*graphVersion=*/0L);
+    assertFalse(entry.isDirty());
+    assertTrue(entry.isDone());
+    entry.markDirty(/*isChanged=*/false);
+    assertTrue(entry.isDirty());
+    assertFalse(entry.isChanged());
+    assertFalse(entry.isDone());
+    assertTrue(entry.isReady());
+    entry.markDirty(/*isChanged=*/true);
+    assertTrue(entry.isDirty());
+    assertTrue(entry.isChanged());
+    assertFalse(entry.isDone());
+    assertTrue(entry.isReady());
+  }
+
+
+  @Test
+  public void markChangedThenDirty() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    addTemporaryDirectDep(entry, key("dep"));
+    entry.signalDep();
+    setValue(entry, new SkyValue() {}, /*errorInfo=*/null, /*graphVersion=*/0L);
+    assertFalse(entry.isDirty());
+    assertTrue(entry.isDone());
+    entry.markDirty(/*isChanged=*/true);
+    assertTrue(entry.isDirty());
+    assertTrue(entry.isChanged());
+    assertFalse(entry.isDone());
+    assertTrue(entry.isReady());
+    entry.markDirty(/*isChanged=*/false);
+    assertTrue(entry.isDirty());
+    assertTrue(entry.isChanged());
+    assertFalse(entry.isDone());
+    assertTrue(entry.isReady());
+  }
+
+  @Test
+  public void crashOnTwiceMarkedChanged() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    setValue(entry, new SkyValue() {}, /*errorInfo=*/null, /*graphVersion=*/0L);
+    assertFalse(entry.isDirty());
+    assertTrue(entry.isDone());
+    entry.markDirty(/*isChanged=*/true);
+    try {
+      entry.markDirty(/*isChanged=*/true);
+      fail("Cannot mark entry changed twice");
+    } catch (IllegalStateException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void crashOnTwiceMarkedDirty() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    addTemporaryDirectDep(entry, key("dep"));
+    entry.signalDep();
+    setValue(entry, new SkyValue() {}, /*errorInfo=*/null, /*graphVersion=*/0L);
+    entry.markDirty(/*isChanged=*/false);
+    try {
+      entry.markDirty(/*isChanged=*/false);
+      fail("Cannot mark entry dirty twice");
+    } catch (IllegalStateException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void crashOnAddReverseDepTwice() {
+    NodeEntry entry = new NodeEntry();
+    SkyKey parent = key("parent");
+    assertEquals(DependencyState.NEEDS_SCHEDULING, entry.addReverseDepAndCheckIfDone(parent));
+    try {
+      entry.addReverseDepAndCheckIfDone(parent);
+      entry.getReverseDeps();
+      fail("Cannot add same dep twice");
+    } catch (IllegalStateException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void crashOnAddReverseDepTwiceAfterDone() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    setValue(entry, new SkyValue() {}, /*errorInfo=*/null, /*graphVersion=*/0L);
+    SkyKey parent = key("parent");
+    assertEquals(DependencyState.DONE, entry.addReverseDepAndCheckIfDone(parent));
+    try {
+      entry.addReverseDepAndCheckIfDone(parent);
+      // We only check for duplicates when we request all the reverse deps.
+      entry.getReverseDeps();
+      fail("Cannot add same dep twice");
+    } catch (IllegalStateException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void crashOnAddReverseDepBeforeAfterDone() {
+    NodeEntry entry = new NodeEntry();
+    SkyKey parent = key("parent");
+    assertEquals(DependencyState.NEEDS_SCHEDULING, entry.addReverseDepAndCheckIfDone(parent));
+    setValue(entry, new SkyValue() {}, /*errorInfo=*/null, /*graphVersion=*/0L);
+    try {
+      entry.addReverseDepAndCheckIfDone(parent);
+      // We only check for duplicates when we request all the reverse deps.
+      entry.getReverseDeps();
+      fail("Cannot add same dep twice");
+    } catch (IllegalStateException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void crashOnAddDirtyReverseDep() {
+    NodeEntry entry = new NodeEntry();
+    SkyKey parent = key("parent");
+    assertEquals(DependencyState.NEEDS_SCHEDULING, entry.addReverseDepAndCheckIfDone(parent));
+    try {
+      entry.addReverseDepAndCheckIfDone(parent);
+      // We only check for duplicates when we request all the reverse deps.
+      entry.getReverseDeps();
+      fail("Cannot add same dep twice in one build, even if dirty");
+    } catch (IllegalStateException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void pruneBeforeBuild() {
+    NodeEntry entry = new NodeEntry();
+    SkyKey dep = key("dep");
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    addTemporaryDirectDep(entry, dep);
+    entry.signalDep();
+    setValue(entry, new SkyValue() {}, /*errorInfo=*/null, /*graphVersion=*/0L);
+    assertFalse(entry.isDirty());
+    assertTrue(entry.isDone());
+    entry.markDirty(/*isChanged=*/false);
+    assertTrue(entry.isDirty());
+    assertFalse(entry.isChanged());
+    assertFalse(entry.isDone());
+    assertTrue(entry.isReady());
+    SkyKey parent = key("parent");
+    entry.addReverseDepAndCheckIfDone(parent);
+    assertEquals(BuildingState.DirtyState.CHECK_DEPENDENCIES, entry.getDirtyState());
+    assertThat(entry.getNextDirtyDirectDeps()).containsExactly(dep).inOrder();
+    addTemporaryDirectDep(entry, dep);
+    entry.signalDep(new IntVersion(0L));
+    assertEquals(BuildingState.DirtyState.VERIFIED_CLEAN, entry.getDirtyState());
+    assertThat(entry.markClean()).containsExactly(parent);
+    assertTrue(entry.isDone());
+    assertEquals(new IntVersion(0L), entry.getVersion());
+  }
+
+  private static class IntegerValue implements SkyValue {
+    private final int value;
+
+    IntegerValue(int value) {
+      this.value = value;
+    }
+
+    @Override
+    public boolean equals(Object that) {
+      return (that instanceof IntegerValue) && (((IntegerValue) that).value == value);
+    }
+
+    @Override
+    public int hashCode() {
+      return value;
+    }
+  }
+
+  @Test
+  public void pruneAfterBuild() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    SkyKey dep = key("dep");
+    addTemporaryDirectDep(entry, dep);
+    entry.signalDep();
+    setValue(entry, new IntegerValue(5), /*errorInfo=*/null, /*graphVersion=*/0L);
+    entry.markDirty(/*isChanged=*/false);
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    assertEquals(BuildingState.DirtyState.CHECK_DEPENDENCIES, entry.getDirtyState());
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    assertThat(entry.getNextDirtyDirectDeps()).containsExactly(dep).inOrder();
+    addTemporaryDirectDep(entry, dep);
+    entry.signalDep(new IntVersion(1L));
+    assertEquals(BuildingState.DirtyState.REBUILDING, entry.getDirtyState());
+    assertThat(entry.getTemporaryDirectDeps()).containsExactly(dep);
+    setValue(entry, new IntegerValue(5), /*errorInfo=*/null, /*graphVersion=*/1L);
+    assertTrue(entry.isDone());
+    assertEquals(new IntVersion(0L), entry.getVersion());
+  }
+
+
+  @Test
+  public void noPruneWhenDetailsChange() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    SkyKey dep = key("dep");
+    addTemporaryDirectDep(entry, dep);
+    entry.signalDep();
+    setValue(entry, new IntegerValue(5), /*errorInfo=*/null, /*graphVersion=*/0L);
+    assertFalse(entry.isDirty());
+    assertTrue(entry.isDone());
+    entry.markDirty(/*isChanged=*/false);
+    assertTrue(entry.isDirty());
+    assertFalse(entry.isChanged());
+    assertFalse(entry.isDone());
+    assertTrue(entry.isReady());
+    SkyKey parent = key("parent");
+    entry.addReverseDepAndCheckIfDone(parent);
+    assertEquals(BuildingState.DirtyState.CHECK_DEPENDENCIES, entry.getDirtyState());
+    assertThat(entry.getNextDirtyDirectDeps()).containsExactly(dep).inOrder();
+    addTemporaryDirectDep(entry, dep);
+    entry.signalDep(new IntVersion(1L));
+    assertEquals(BuildingState.DirtyState.REBUILDING, entry.getDirtyState());
+    assertThat(entry.getTemporaryDirectDeps()).containsExactly(dep);
+    ReifiedSkyFunctionException exception = new ReifiedSkyFunctionException(
+        new GenericFunctionException(new SomeErrorException("oops"), Transience.PERSISTENT),
+        key("cause"));
+    setValue(entry, new IntegerValue(5), new ErrorInfo(exception),
+        /*graphVersion=*/1L);
+    assertTrue(entry.isDone());
+    assertEquals("Version increments when setValue changes", new IntVersion(1), entry.getVersion());
+  }
+
+  @Test
+  public void pruneErrorValue() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    SkyKey dep = key("dep");
+    addTemporaryDirectDep(entry, dep);
+    entry.signalDep();
+    ReifiedSkyFunctionException exception = new ReifiedSkyFunctionException(
+        new GenericFunctionException(new SomeErrorException("oops"), Transience.PERSISTENT),
+        key("cause"));
+    ErrorInfo errorInfo = new ErrorInfo(exception);
+    setValue(entry, /*value=*/null, errorInfo, /*graphVersion=*/0L);
+    entry.markDirty(/*isChanged=*/false);
+    entry.addReverseDepAndCheckIfDone(null); // Restart evaluation.
+    assertEquals(BuildingState.DirtyState.CHECK_DEPENDENCIES, entry.getDirtyState());
+    assertThat(entry.getNextDirtyDirectDeps()).containsExactly(dep).inOrder();
+    addTemporaryDirectDep(entry, dep);
+    entry.signalDep(new IntVersion(1L));
+    assertEquals(BuildingState.DirtyState.REBUILDING, entry.getDirtyState());
+    assertThat(entry.getTemporaryDirectDeps()).containsExactly(dep);
+    setValue(entry, /*value=*/null, errorInfo, /*graphVersion=*/1L);
+    assertTrue(entry.isDone());
+    assertEquals(new IntVersion(0L), entry.getVersion());
+  }
+
+  @Test
+  public void getDependencyGroup() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    SkyKey dep = key("dep");
+    SkyKey dep2 = key("dep2");
+    SkyKey dep3 = key("dep3");
+    addTemporaryDirectDeps(entry, dep, dep2);
+    addTemporaryDirectDep(entry, dep3);
+    entry.signalDep();
+    entry.signalDep();
+    entry.signalDep();
+    setValue(entry, /*value=*/new IntegerValue(5), null, 0L);
+    entry.markDirty(/*isChanged=*/false);
+    entry.addReverseDepAndCheckIfDone(null); // Restart evaluation.
+    assertEquals(BuildingState.DirtyState.CHECK_DEPENDENCIES, entry.getDirtyState());
+    assertThat(entry.getNextDirtyDirectDeps()).containsExactly(dep, dep2).inOrder();
+    addTemporaryDirectDeps(entry, dep, dep2);
+    entry.signalDep(new IntVersion(0L));
+    entry.signalDep(new IntVersion(0L));
+    assertEquals(BuildingState.DirtyState.CHECK_DEPENDENCIES, entry.getDirtyState());
+    assertThat(entry.getNextDirtyDirectDeps()).containsExactly(dep3).inOrder();
+  }
+
+  @Test
+  public void maintainDependencyGroupAfterRemoval() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    SkyKey dep = key("dep");
+    SkyKey dep2 = key("dep2");
+    SkyKey dep3 = key("dep3");
+    SkyKey dep4 = key("dep4");
+    SkyKey dep5 = key("dep5");
+    addTemporaryDirectDeps(entry, dep, dep2, dep3);
+    addTemporaryDirectDep(entry, dep4);
+    addTemporaryDirectDep(entry, dep5);
+    entry.signalDep();
+    entry.signalDep();
+    // Oops! Evaluation terminated with an error, but we're going to set this entry's value anyway.
+    entry.removeUnfinishedDeps(ImmutableSet.of(dep2, dep3, dep5));
+    ReifiedSkyFunctionException exception = new ReifiedSkyFunctionException(
+        new GenericFunctionException(new SomeErrorException("oops"), Transience.PERSISTENT),
+        key("key"));
+    setValue(entry, null, new ErrorInfo(exception), 0L);
+    entry.markDirty(/*isChanged=*/false);
+    entry.addReverseDepAndCheckIfDone(null); // Restart evaluation.
+    assertEquals(BuildingState.DirtyState.CHECK_DEPENDENCIES, entry.getDirtyState());
+    assertThat(entry.getNextDirtyDirectDeps()).containsExactly(dep).inOrder();
+    addTemporaryDirectDep(entry, dep);
+    entry.signalDep(new IntVersion(0L));
+    assertEquals(BuildingState.DirtyState.CHECK_DEPENDENCIES, entry.getDirtyState());
+    assertThat(entry.getNextDirtyDirectDeps()).containsExactly(dep4).inOrder();
+  }
+
+  @Test
+  public void noPruneWhenDepsChange() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    SkyKey dep = key("dep");
+    addTemporaryDirectDep(entry, dep);
+    entry.signalDep();
+    setValue(entry, new IntegerValue(5), /*errorInfo=*/null, /*graphVersion=*/0L);
+    entry.markDirty(/*isChanged=*/false);
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    assertEquals(BuildingState.DirtyState.CHECK_DEPENDENCIES, entry.getDirtyState());
+    assertThat(entry.getNextDirtyDirectDeps()).containsExactly(dep).inOrder();
+    addTemporaryDirectDep(entry, dep);
+    assertTrue(entry.signalDep(new IntVersion(1L)));
+    assertEquals(BuildingState.DirtyState.REBUILDING, entry.getDirtyState());
+    assertThat(entry.getTemporaryDirectDeps()).containsExactly(dep);
+    addTemporaryDirectDep(entry, key("dep2"));
+    assertTrue(entry.signalDep(new IntVersion(1L)));
+    setValue(entry, new IntegerValue(5), /*errorInfo=*/null, /*graphVersion=*/1L);
+    assertTrue(entry.isDone());
+    assertEquals("Version increments when deps change", new IntVersion(1L), entry.getVersion());
+  }
+
+  @Test
+  public void checkDepsOneByOne() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(null); // Start evaluation.
+    List<SkyKey> deps = new ArrayList<>();
+    for (int ii = 0; ii < 10; ii++) {
+      SkyKey dep = key(Integer.toString(ii));
+      deps.add(dep);
+      addTemporaryDirectDep(entry, dep);
+      entry.signalDep();
+    }
+    setValue(entry, new IntegerValue(5), /*errorInfo=*/null, /*graphVersion=*/0L);
+    entry.markDirty(/*isChanged=*/false);
+    entry.addReverseDepAndCheckIfDone(null); // Start new evaluation.
+    assertEquals(BuildingState.DirtyState.CHECK_DEPENDENCIES, entry.getDirtyState());
+    for (int ii = 0; ii < 10; ii++) {
+      assertThat(entry.getNextDirtyDirectDeps()).containsExactly(deps.get(ii)).inOrder();
+      addTemporaryDirectDep(entry, deps.get(ii));
+      assertTrue(entry.signalDep(new IntVersion(0L)));
+      if (ii < 9) {
+        assertEquals(BuildingState.DirtyState.CHECK_DEPENDENCIES, entry.getDirtyState());
+      } else {
+        assertEquals(BuildingState.DirtyState.VERIFIED_CLEAN, entry.getDirtyState());
+      }
+    }
+  }
+
+  @Test
+  public void signalOnlyNewParents() {
+    NodeEntry entry = new NodeEntry();
+    entry.addReverseDepAndCheckIfDone(key("parent"));
+    setValue(entry, new SkyValue() {}, /*errorInfo=*/null, /*graphVersion=*/0L);
+    entry.markDirty(/*isChanged=*/true);
+    SkyKey newParent = key("new parent");
+    entry.addReverseDepAndCheckIfDone(newParent);
+    assertEquals(BuildingState.DirtyState.REBUILDING, entry.getDirtyState());
+    assertThat(setValue(entry, new SkyValue() {}, /*errorInfo=*/null,
+        /*graphVersion=*/1L)).containsExactly(newParent);
+  }
+
+  @Test
+  public void testClone() {
+    NodeEntry entry = new NodeEntry();
+    IntVersion version = new IntVersion(0);
+    IntegerValue originalValue = new IntegerValue(42);
+    SkyKey originalChild = key("child");
+    addTemporaryDirectDep(entry, originalChild);
+    entry.signalDep();
+    entry.setValue(originalValue, version);
+    entry.addReverseDepAndCheckIfDone(key("parent1"));
+    NodeEntry clone1 = entry.cloneNodeEntry();
+    entry.addReverseDepAndCheckIfDone(key("parent2"));
+    NodeEntry clone2 = entry.cloneNodeEntry();
+    entry.removeReverseDep(key("parent1"));
+    entry.removeReverseDep(key("parent2"));
+    IntegerValue updatedValue = new IntegerValue(52);
+    clone2.markDirty(true);
+    clone2.addReverseDepAndCheckIfDone(null);
+    SkyKey newChild = key("newchild");
+    addTemporaryDirectDep(clone2, newChild);
+    clone2.signalDep();
+    clone2.setValue(updatedValue, version.next());
+
+    assertThat(entry.getVersion()).isEqualTo(version);
+    assertThat(clone1.getVersion()).isEqualTo(version);
+    assertThat(clone2.getVersion()).isEqualTo(version.next());
+
+    assertThat(entry.getValue()).isEqualTo(originalValue);
+    assertThat(clone1.getValue()).isEqualTo(originalValue);
+    assertThat(clone2.getValue()).isEqualTo(updatedValue);
+
+    assertThat(entry.getDirectDeps()).containsExactly(originalChild);
+    assertThat(clone1.getDirectDeps()).containsExactly(originalChild);
+    assertThat(clone2.getDirectDeps()).containsExactly(newChild);
+
+    assertThat(entry.getReverseDeps()).hasSize(0);
+    assertThat(clone1.getReverseDeps()).containsExactly(key("parent1"));
+    assertThat(clone2.getReverseDeps()).containsExactly(key("parent1"), key("parent2"));
+  }
+
+  private static Set<SkyKey> setValue(NodeEntry entry, SkyValue value,
+      @Nullable ErrorInfo errorInfo, long graphVersion) {
+    return entry.setValue(ValueWithMetadata.normal(value, errorInfo, NO_EVENTS),
+        new IntVersion(graphVersion));
+  }
+
+  private static void addTemporaryDirectDep(NodeEntry entry, SkyKey key) {
+    GroupedListHelper<SkyKey> helper = new GroupedListHelper<>();
+    helper.add(key);
+    entry.addTemporaryDirectDeps(helper);
+  }
+
+  private static void addTemporaryDirectDeps(NodeEntry entry, SkyKey... keys) {
+    GroupedListHelper<SkyKey> helper = new GroupedListHelper<>();
+    helper.startGroup();
+    for (SkyKey key : keys) {
+      helper.add(key);
+    }
+    helper.endGroup();
+    entry.addTemporaryDirectDeps(helper);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/skyframe/NotifyingInMemoryGraph.java b/src/test/java/com/google/devtools/build/skyframe/NotifyingInMemoryGraph.java
new file mode 100644
index 0000000..aa88278
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skyframe/NotifyingInMemoryGraph.java
@@ -0,0 +1,128 @@
+// Copyright 2014 Google Inc. 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.skyframe;
+
+import com.google.devtools.build.lib.concurrent.ThreadSafety.ThreadSafe;
+import com.google.devtools.build.lib.util.Pair;
+
+import java.util.Set;
+
+/**
+ * Class that allows clients to be notified on each access of the graph. Clients can simply track
+ * accesses, or they can block to achieve desired synchronization.
+ */
+public class NotifyingInMemoryGraph extends InMemoryGraph {
+  private final Listener graphListener;
+
+  public NotifyingInMemoryGraph(Listener graphListener) {
+    this.graphListener = graphListener;
+  }
+
+  @Override
+  public NodeEntry createIfAbsent(SkyKey key) {
+    graphListener.accept(key, EventType.CREATE_IF_ABSENT, Order.BEFORE, null);
+    NodeEntry newval = getEntry(key);
+    NodeEntry oldval = getNodeMap().putIfAbsent(key, newval);
+    return oldval == null ? newval : oldval;
+  }
+
+  // Subclasses should override if they wish to subclass NotifyingNodeEntry.
+  protected NotifyingNodeEntry getEntry(SkyKey key) {
+    return new NotifyingNodeEntry(key);
+  }
+
+  /** Receiver to be informed when an event for a given key occurs. */
+  public interface Listener {
+    @ThreadSafe
+    void accept(SkyKey key, EventType type, Order order, Object context);
+
+    public static Listener NULL_LISTENER = new Listener() {
+      @Override
+      public void accept(SkyKey key, EventType type, Order order, Object context) {}
+    };
+  }
+
+  /**
+   * Graph/value entry events that the receiver can be informed of. When writing tests, feel free to
+   * add additional events here if needed.
+   */
+  public enum EventType {
+    CREATE_IF_ABSENT,
+    ADD_REVERSE_DEP,
+    SIGNAL,
+    SET_VALUE,
+    MARK_DIRTY,
+    IS_CHANGED,
+    IS_DIRTY
+  }
+
+  public enum Order {
+    BEFORE,
+    AFTER
+  }
+
+  protected class NotifyingNodeEntry extends NodeEntry {
+    private final SkyKey myKey;
+
+    protected NotifyingNodeEntry(SkyKey key) {
+      myKey = key;
+    }
+
+    // Note that these methods are not synchronized. Necessary synchronization happens when calling
+    // the super() methods.
+    @Override
+    DependencyState addReverseDepAndCheckIfDone(SkyKey reverseDep) {
+      graphListener.accept(myKey, EventType.ADD_REVERSE_DEP, Order.BEFORE, reverseDep);
+      DependencyState result = super.addReverseDepAndCheckIfDone(reverseDep);
+      graphListener.accept(myKey, EventType.ADD_REVERSE_DEP, Order.AFTER, reverseDep);
+      return result;
+    }
+
+    @Override
+    boolean signalDep(Version childVersion) {
+      graphListener.accept(myKey, EventType.SIGNAL, Order.BEFORE, childVersion);
+      boolean result = super.signalDep(childVersion);
+      graphListener.accept(myKey, EventType.SIGNAL, Order.AFTER, childVersion);
+      return result;
+    }
+
+    @Override
+    public Set<SkyKey> setValue(SkyValue value, Version version) {
+      graphListener.accept(myKey, EventType.SET_VALUE, Order.BEFORE, value);
+      Set<SkyKey> result = super.setValue(value, version);
+      graphListener.accept(myKey, EventType.SET_VALUE, Order.AFTER, value);
+      return result;
+    }
+
+    @Override
+    Pair<? extends Iterable<SkyKey>, ? extends SkyValue> markDirty(boolean isChanged) {
+      graphListener.accept(myKey, EventType.MARK_DIRTY, Order.BEFORE, isChanged);
+      Pair<? extends Iterable<SkyKey>, ? extends SkyValue> result = super.markDirty(isChanged);
+      graphListener.accept(myKey, EventType.MARK_DIRTY, Order.AFTER, isChanged);
+      return result;
+    }
+
+    @Override
+    boolean isChanged() {
+      graphListener.accept(myKey, EventType.IS_CHANGED, Order.BEFORE, this);
+      return super.isChanged();
+    }
+
+    @Override
+    public boolean isDirty() {
+      graphListener.accept(myKey, EventType.IS_DIRTY, Order.BEFORE, this);
+      return super.isDirty();
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/skyframe/ParallelEvaluatorTest.java b/src/test/java/com/google/devtools/build/skyframe/ParallelEvaluatorTest.java
new file mode 100644
index 0000000..5394437
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skyframe/ParallelEvaluatorTest.java
@@ -0,0 +1,2260 @@
+// Copyright 2014 Google Inc. 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.skyframe;
+
+import static com.google.common.collect.Iterables.getOnlyElement;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.devtools.build.skyframe.GraphTester.CONCATENATE;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.common.util.concurrent.Uninterruptibles;
+import com.google.devtools.build.lib.events.Event;
+import com.google.devtools.build.lib.events.EventCollector;
+import com.google.devtools.build.lib.events.EventHandler;
+import com.google.devtools.build.lib.events.EventKind;
+import com.google.devtools.build.lib.events.OutputFilter.RegexOutputFilter;
+import com.google.devtools.build.lib.events.Reporter;
+import com.google.devtools.build.lib.testutil.JunitTestUtils;
+import com.google.devtools.build.lib.testutil.MoreAsserts;
+import com.google.devtools.build.lib.testutil.TestThread;
+import com.google.devtools.build.lib.testutil.TestUtils;
+import com.google.devtools.build.skyframe.GraphTester.StringValue;
+import com.google.devtools.build.skyframe.NotifyingInMemoryGraph.EventType;
+import com.google.devtools.build.skyframe.NotifyingInMemoryGraph.Listener;
+import com.google.devtools.build.skyframe.NotifyingInMemoryGraph.Order;
+import com.google.devtools.build.skyframe.SkyFunctionException.Transience;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import javax.annotation.Nullable;
+
+/**
+ * Tests for {@link ParallelEvaluator}.
+ */
+@RunWith(JUnit4.class)
+public class ParallelEvaluatorTest {
+  protected ProcessableGraph graph;
+  protected IntVersion graphVersion = new IntVersion(0);
+  protected GraphTester tester = new GraphTester();
+
+  private EventCollector eventCollector;
+  private EventHandler reporter;
+
+  private EvaluationProgressReceiver revalidationReceiver;
+
+  @Before
+  public void initializeReporter() {
+    eventCollector = new EventCollector(EventKind.ALL_EVENTS);
+    reporter = new Reporter(eventCollector);
+  }
+
+  private ParallelEvaluator makeEvaluator(ProcessableGraph graph,
+      ImmutableMap<SkyFunctionName, ? extends SkyFunction> builders, boolean keepGoing) {
+    Version oldGraphVersion = graphVersion;
+    graphVersion = graphVersion.next();
+    return new ParallelEvaluator(graph, oldGraphVersion,
+        builders, reporter,  new MemoizingEvaluator.EmittedEventState(), keepGoing,
+        150, revalidationReceiver, new DirtyKeyTrackerImpl());
+  }
+
+  /** Convenience method for eval-ing a single value. */
+  protected SkyValue eval(boolean keepGoing, SkyKey key) throws InterruptedException {
+    return eval(keepGoing, ImmutableList.of(key)).get(key);
+  }
+
+  protected ErrorInfo evalValueInError(SkyKey key) throws InterruptedException {
+    return eval(true, ImmutableList.of(key)).getError(key);
+  }
+
+  protected <T extends SkyValue> EvaluationResult<T> eval(boolean keepGoing, SkyKey... keys)
+      throws InterruptedException {
+    return eval(keepGoing, ImmutableList.copyOf(keys));
+  }
+
+  protected <T extends SkyValue> EvaluationResult<T> eval(boolean keepGoing, Iterable<SkyKey> keys)
+      throws InterruptedException {
+    ParallelEvaluator evaluator = makeEvaluator(graph,
+        ImmutableMap.of(GraphTester.NODE_TYPE, tester.createDelegatingFunction()),
+        keepGoing);
+    return evaluator.eval(keys);
+  }
+
+  protected GraphTester.TestFunction set(String name, String value) {
+    return tester.set(name, new StringValue(value));
+  }
+
+  @Test
+  public void smoke() throws Exception {
+    graph = new InMemoryGraph();
+    set("a", "a");
+    set("b", "b");
+    tester.getOrCreate("ab").addDependency("a").addDependency("b").setComputedValue(CONCATENATE);
+    StringValue value = (StringValue) eval(false, GraphTester.toSkyKey("ab"));
+    assertEquals("ab", value.getValue());
+    JunitTestUtils.assertNoEvents(eventCollector);
+  }
+
+  /**
+   * Test interruption handling when a long-running SkyFunction gets interrupted.
+   */
+  @Test
+  public void interruptedFunction() throws Exception {
+    runInterruptionTest(new SkyFunctionFactory() {
+      @Override
+      public SkyFunction create(final Semaphore threadStarted, final String[] errorMessage) {
+        return new SkyFunction() {
+          @Override
+          public SkyValue compute(SkyKey key, Environment env) throws InterruptedException {
+            // Signal the waiting test thread that the evaluator thread has really started.
+            threadStarted.release();
+
+            // Simulate a SkyFunction that runs for 10 seconds (this number was chosen arbitrarily).
+            // The main thread should interrupt it shortly after it got started.
+            Thread.sleep(10 * 1000);
+
+            // Set an error message to indicate that the expected interruption didn't happen.
+            // We can't use Assert.fail(String) on an async thread.
+            errorMessage[0] = "SkyFunction should have been interrupted";
+            return null;
+          }
+
+          @Nullable
+          @Override
+          public String extractTag(SkyKey skyKey) {
+            return null;
+          }
+        };
+      }
+    });
+  }
+
+  /**
+   * Test interruption handling when the Evaluator is in-between running SkyFunctions.
+   *
+   * <p>This is the point in time after a SkyFunction requested a dependency which is not yet built
+   * so the builder returned null to the Evaluator, and the latter is about to schedule evaluation
+   * of the missing dependency but gets interrupted before the dependency's SkyFunction could start.
+   */
+  @Test
+  public void interruptedEvaluatorThread() throws Exception {
+    runInterruptionTest(new SkyFunctionFactory() {
+      @Override
+      public SkyFunction create(final Semaphore threadStarted, final String[] errorMessage) {
+        return new SkyFunction() {
+          // No need to synchronize access to this field; we always request just one more
+          // dependency, so it's only one SkyFunction running at any time.
+          private int valueIdCounter = 0;
+
+          @Override
+          public SkyValue compute(SkyKey key, Environment env) {
+            // Signal the waiting test thread that the Evaluator thread has really started.
+            threadStarted.release();
+
+            // Keep the evaluator busy until the test's thread gets scheduled and can
+            // interrupt the Evaluator's thread.
+            env.getValue(GraphTester.toSkyKey("a" + valueIdCounter++));
+
+            // This method never throws InterruptedException, therefore it's the responsibility
+            // of the Evaluator to detect the interrupt and avoid calling subsequent SkyFunctions.
+            return null;
+          }
+
+          @Nullable
+          @Override
+          public String extractTag(SkyKey skyKey) {
+            return null;
+          }
+        };
+      }
+    });
+  }
+
+  private void runPartialResultOnInterruption(boolean buildFastFirst) throws Exception {
+    graph = new InMemoryGraph();
+    // Two runs for fastKey's builder and one for the start of waitKey's builder.
+    final CountDownLatch allValuesReady = new CountDownLatch(3);
+    final SkyKey waitKey = GraphTester.toSkyKey("wait");
+    final SkyKey fastKey = GraphTester.toSkyKey("fast");
+    SkyKey leafKey = GraphTester.toSkyKey("leaf");
+    tester.getOrCreate(waitKey).setBuilder(new SkyFunction() {
+          @Override
+          public SkyValue compute(SkyKey skyKey, Environment env) throws InterruptedException {
+            allValuesReady.countDown();
+            Thread.sleep(10000);
+            throw new AssertionError("Should have been interrupted");
+          }
+
+          @Override
+          public String extractTag(SkyKey skyKey) {
+            return null;
+          }
+        });
+    tester.getOrCreate(fastKey).setBuilder(new ChainedFunction(null, null, allValuesReady, false,
+        new StringValue("fast"), ImmutableList.of(leafKey)));
+    tester.set(leafKey, new StringValue("leaf"));
+    if (buildFastFirst) {
+      eval(/*keepGoing=*/false, fastKey);
+    }
+    final Set<SkyKey> receivedValues = Sets.newConcurrentHashSet();
+    revalidationReceiver = new EvaluationProgressReceiver() {
+      @Override
+      public void invalidated(SkyValue value, InvalidationState state) {}
+
+      @Override
+      public void enqueueing(SkyKey key) {}
+
+      @Override
+      public void evaluated(SkyKey skyKey, SkyValue value, EvaluationState state) {
+        receivedValues.add(skyKey);
+      }
+    };
+    TestThread evalThread = new TestThread() {
+      @Override
+      public void runTest() throws Exception {
+        try {
+          eval(/*keepGoing=*/true, waitKey, fastKey);
+          fail();
+        } catch (InterruptedException e) {
+          // Expected.
+        }
+      }
+    };
+    evalThread.start();
+    assertTrue(allValuesReady.await(TestUtils.WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+    evalThread.interrupt();
+    evalThread.join(TestUtils.WAIT_TIMEOUT_MILLISECONDS);
+    assertFalse(evalThread.isAlive());
+    if (buildFastFirst) {
+      // If leafKey was already built, it is not reported to the receiver.
+      assertThat(receivedValues).containsExactly(fastKey);
+    } else {
+      // On first time being built, leafKey is registered too.
+      assertThat(receivedValues).containsExactly(fastKey, leafKey);
+    }
+  }
+
+  @Test
+  public void partialResultOnInterruption() throws Exception {
+    runPartialResultOnInterruption(/*buildFastFirst=*/false);
+  }
+
+  @Test
+  public void partialCachedResultOnInterruption() throws Exception {
+    runPartialResultOnInterruption(/*buildFastFirst=*/true);
+  }
+
+  /**
+   * Factory for SkyFunctions for interruption testing (see {@link #runInterruptionTest}).
+   */
+  private interface SkyFunctionFactory {
+    /**
+     * Creates a SkyFunction suitable for a specific test scenario.
+     *
+     * @param threadStarted a latch which the returned SkyFunction must
+     *     {@link Semaphore#release() release} once it started (otherwise the test won't work)
+     * @param errorMessage a single-element array; the SkyFunction can put a error message in it
+     *     to indicate that an assertion failed (calling {@code fail} from async thread doesn't
+     *     work)
+     */
+    SkyFunction create(final Semaphore threadStarted, final String[] errorMessage);
+  }
+
+  /**
+   * Test that we can handle the Evaluator getting interrupted at various points.
+   *
+   * <p>This method creates an Evaluator with the specified SkyFunction for GraphTested.NODE_TYPE,
+   * then starts a thread, requests evaluation and asserts that evaluation started. It then
+   * interrupts the Evaluator thread and asserts that it acknowledged the interruption.
+   *
+   * @param valueBuilderFactory creates a SkyFunction which may or may not handle interruptions
+   *     (depending on the test)
+   */
+  private void runInterruptionTest(SkyFunctionFactory valueBuilderFactory) throws Exception {
+    final Semaphore threadStarted = new Semaphore(0);
+    final Semaphore threadInterrupted = new Semaphore(0);
+    final String[] wasError = new String[] { null };
+    final ParallelEvaluator evaluator = makeEvaluator(new InMemoryGraph(),
+        ImmutableMap.of(GraphTester.NODE_TYPE, valueBuilderFactory.create(threadStarted, wasError)),
+        false);
+
+    Thread t = new Thread(new Runnable() {
+        @Override
+        public void run() {
+          try {
+            evaluator.eval(ImmutableList.of(GraphTester.toSkyKey("a")));
+
+            // There's no real need to set an error here. If the thread is not interrupted then
+            // threadInterrupted is not released and the test thread will fail to acquire it.
+            wasError[0] = "evaluation should have been interrupted";
+          } catch (InterruptedException e) {
+            // This is the interrupt we are waiting for. It should come straight from the
+            // evaluator (more precisely, the AbstractQueueVisitor).
+            // Signal the waiting test thread that the interrupt was acknowledged.
+            threadInterrupted.release();
+          }
+        }
+    });
+
+    // Start the thread and wait for a semaphore. This ensures that the thread was really started.
+    t.start();
+    assertTrue(threadStarted.tryAcquire(TestUtils.WAIT_TIMEOUT_MILLISECONDS,
+        TimeUnit.MILLISECONDS));
+
+    // Interrupt the thread and wait for a semaphore. This ensures that the thread was really
+    // interrupted and this fact was acknowledged.
+    t.interrupt();
+    assertTrue(threadInterrupted.tryAcquire(TestUtils.WAIT_TIMEOUT_MILLISECONDS,
+        TimeUnit.MILLISECONDS));
+
+    // The SkyFunction may have reported an error.
+    if (wasError[0] != null) {
+      fail(wasError[0]);
+    }
+
+    // Wait for the thread to finish.
+    t.join(TestUtils.WAIT_TIMEOUT_MILLISECONDS);
+  }
+
+  @Test
+  public void unrecoverableError() throws Exception {
+    class CustomRuntimeException extends RuntimeException {}
+    final CustomRuntimeException expected = new CustomRuntimeException();
+
+    final SkyFunction builder = new SkyFunction() {
+      @Override
+      @Nullable
+      public SkyValue compute(SkyKey skyKey, Environment env)
+          throws SkyFunctionException, InterruptedException {
+        throw expected;
+      }
+
+      @Override
+      @Nullable
+      public String extractTag(SkyKey skyKey) {
+        return null;
+      }
+    };
+
+    final ParallelEvaluator evaluator = makeEvaluator(new InMemoryGraph(),
+        ImmutableMap.of(GraphTester.NODE_TYPE, builder),
+        false);
+
+    SkyKey valueToEval = GraphTester.toSkyKey("a");
+    try {
+      evaluator.eval(ImmutableList.of(valueToEval));
+    } catch (RuntimeException re) {
+      assertTrue(re.getMessage()
+          .contains("Unrecoverable error while evaluating node '" + valueToEval.toString() + "'"));
+      assertTrue(re.getCause() instanceof CustomRuntimeException);
+    }
+  }
+
+  @Test
+  public void simpleWarning() throws Exception {
+    graph = new InMemoryGraph();
+    set("a", "a").setWarning("warning on 'a'");
+    StringValue value = (StringValue) eval(false, GraphTester.toSkyKey("a"));
+    assertEquals("a", value.getValue());
+    JunitTestUtils.assertContainsEvent(eventCollector, "warning on 'a'");
+    JunitTestUtils.assertEventCount(1, eventCollector);
+  }
+
+  @Test
+  public void warningMatchesRegex() throws Exception {
+    graph = new InMemoryGraph();
+    ((Reporter) reporter).setOutputFilter(RegexOutputFilter.forRegex("a"));
+    set("example", "a value").setWarning("warning message");
+    SkyKey a = GraphTester.toSkyKey("example");
+    tester.getOrCreate(a).setTag("a");
+    StringValue value = (StringValue) eval(false, a);
+    assertEquals("a value", value.getValue());
+    JunitTestUtils.assertContainsEvent(eventCollector, "warning message");
+    JunitTestUtils.assertEventCount(1, eventCollector);
+  }
+
+  @Test
+  public void warningMatchesRegexOnlyTag() throws Exception {
+    graph = new InMemoryGraph();
+    ((Reporter) reporter).setOutputFilter(RegexOutputFilter.forRegex("a"));
+    set("a", "a value").setWarning("warning on 'a'");
+    SkyKey a = GraphTester.toSkyKey("a");
+    tester.getOrCreate(a).setTag("b");
+    StringValue value = (StringValue) eval(false, a);
+    assertEquals("a value", value.getValue());
+    JunitTestUtils.assertEventCount(0, eventCollector);  }
+
+  @Test
+  public void warningDoesNotMatchRegex() throws Exception {
+    graph = new InMemoryGraph();
+    ((Reporter) reporter).setOutputFilter(RegexOutputFilter.forRegex("b"));
+    set("a", "a").setWarning("warning on 'a'");
+    SkyKey a = GraphTester.toSkyKey("a");
+    tester.getOrCreate(a).setTag("a");
+    StringValue value = (StringValue) eval(false, a);
+    assertEquals("a", value.getValue());
+    JunitTestUtils.assertEventCount(0, eventCollector);
+  }
+
+  /** Regression test: events from already-done value not replayed. */
+  @Test
+  public void eventFromDoneChildRecorded() throws Exception {
+    graph = new InMemoryGraph();
+    set("a", "a").setWarning("warning on 'a'");
+    SkyKey a = GraphTester.toSkyKey("a");
+    SkyKey top = GraphTester.toSkyKey("top");
+    tester.getOrCreate(top).addDependency(a).setComputedValue(CONCATENATE);
+    // Build a so that it is already in the graph.
+    eval(false, a);
+    JunitTestUtils.assertEventCount(1, eventCollector);
+    eventCollector.clear();
+    // Build top. The warning from a should be reprinted.
+    eval(false, top);
+    JunitTestUtils.assertEventCount(1, eventCollector);
+    eventCollector.clear();
+    // Build top again. The warning should have been stored in the value.
+    eval(false, top);
+    JunitTestUtils.assertEventCount(1, eventCollector);
+  }
+
+  @Test
+  public void shouldCreateErrorValueWithRootCause() throws Exception {
+    graph = new InMemoryGraph();
+    set("a", "a");
+    SkyKey parentErrorKey = GraphTester.toSkyKey("parent");
+    SkyKey errorKey = GraphTester.toSkyKey("error");
+    tester.getOrCreate(parentErrorKey).addDependency("a").addDependency(errorKey)
+    .setComputedValue(CONCATENATE);
+    tester.getOrCreate(errorKey).setHasError(true);
+    ErrorInfo error = evalValueInError(parentErrorKey);
+    assertThat(error.getRootCauses()).containsExactly(errorKey);
+  }
+
+  @Test
+  public void shouldBuildOneTarget() throws Exception {
+    graph = new InMemoryGraph();
+    set("a", "a");
+    set("b", "b");
+    SkyKey parentErrorKey = GraphTester.toSkyKey("parent");
+    SkyKey errorFreeKey = GraphTester.toSkyKey("ab");
+    SkyKey errorKey = GraphTester.toSkyKey("error");
+    tester.getOrCreate(parentErrorKey).addDependency(errorKey).addDependency("a")
+    .setComputedValue(CONCATENATE);
+    tester.getOrCreate(errorKey).setHasError(true);
+    tester.getOrCreate(errorFreeKey).addDependency("a").addDependency("b")
+    .setComputedValue(CONCATENATE);
+    EvaluationResult<StringValue> result = eval(true, parentErrorKey, errorFreeKey);
+    ErrorInfo error = result.getError(parentErrorKey);
+    assertThat(error.getRootCauses()).containsExactly(errorKey);
+    StringValue abValue = result.get(errorFreeKey);
+    assertEquals("ab", abValue.getValue());
+  }
+
+  @Test
+  public void catastropheHaltsBuild_KeepGoing_KeepEdges() throws Exception {
+    catastrophicBuild(true, true);
+  }
+
+  @Test
+  public void catastropheHaltsBuild_KeepGoing_NoKeepEdges() throws Exception {
+    catastrophicBuild(true, false);
+  }
+
+  @Test
+  public void catastropheInBuild_NoKeepGoing_KeepEdges() throws Exception {
+    catastrophicBuild(false, true);
+  }
+
+  private void catastrophicBuild(boolean keepGoing, boolean keepEdges) throws Exception {
+    graph = new InMemoryGraph(keepEdges);
+
+    SkyKey catastropheKey = GraphTester.toSkyKey("catastrophe");
+    SkyKey otherKey = GraphTester.toSkyKey("someKey");
+
+    tester.getOrCreate(catastropheKey).setBuilder(new SkyFunction() {
+      @Nullable
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException {
+        throw new SkyFunctionException(new SomeErrorException("bad"),
+            Transience.PERSISTENT) {
+          @Override
+          public boolean isCatastrophic() {
+            return true;
+          }
+        };
+      }
+
+      @Nullable
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        return null;
+      }
+    });
+
+    tester.getOrCreate(otherKey).setBuilder(new SkyFunction() {
+      @Nullable
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws InterruptedException {
+        new CountDownLatch(1).await();
+        throw new RuntimeException("can't get here");
+      }
+
+      @Nullable
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        return null;
+      }
+    });
+
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.getOrCreate(topKey).addDependency(catastropheKey).setComputedValue(CONCATENATE);
+    EvaluationResult<StringValue> result = eval(keepGoing, topKey, otherKey);
+    if (!keepGoing) {
+      ErrorInfo error = result.getError(topKey);
+      assertThat(error.getRootCauses()).containsExactly(catastropheKey);
+    } else {
+      assertTrue(result.hasError());
+      assertThat(result.errorMap()).isEmpty();
+    }
+  }
+
+  @Test
+  public void parentFailureDoesntAffectChild() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    tester.getOrCreate(parentKey).setHasError(true);
+    SkyKey childKey = GraphTester.toSkyKey("child");
+    set("child", "onions");
+    tester.getOrCreate(parentKey).addDependency(childKey).setComputedValue(CONCATENATE);
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/true, parentKey, childKey);
+    // Child is guaranteed to complete successfully before parent can run (and fail),
+    // since parent depends on it.
+    StringValue childValue = result.get(childKey);
+    Assert.assertNotNull(childValue);
+    assertEquals("onions", childValue.getValue());
+    ErrorInfo error = result.getError(parentKey);
+    Assert.assertNotNull(error);
+    assertThat(error.getRootCauses()).containsExactly(parentKey);
+  }
+
+  @Test
+  public void newParentOfErrorShouldHaveError() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey errorKey = GraphTester.toSkyKey("error");
+    tester.getOrCreate(errorKey).setHasError(true);
+    ErrorInfo error = evalValueInError(errorKey);
+    assertThat(error.getRootCauses()).containsExactly(errorKey);
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    tester.getOrCreate(parentKey).addDependency("error").setComputedValue(CONCATENATE);
+    error = evalValueInError(parentKey);
+    assertThat(error.getRootCauses()).containsExactly(errorKey);
+  }
+
+  @Test
+  public void errorTwoLevelsDeep() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    SkyKey errorKey = GraphTester.toSkyKey("error");
+    tester.getOrCreate(errorKey).setHasError(true);
+    tester.getOrCreate("mid").addDependency(errorKey).setComputedValue(CONCATENATE);
+    tester.getOrCreate(parentKey).addDependency("mid").setComputedValue(CONCATENATE);
+    ErrorInfo error = evalValueInError(parentKey);
+    assertThat(error.getRootCauses()).containsExactly(errorKey);
+  }
+
+  /**
+   * A recreation of BuildViewTest#testHasErrorRaceCondition.  Also similar to errorTwoLevelsDeep,
+   * except here we request multiple toplevel values.
+   */
+  @Test
+  public void errorPropagationToTopLevelValues() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    SkyKey midKey = GraphTester.toSkyKey("mid");
+    SkyKey badKey = GraphTester.toSkyKey("bad");
+    tester.getOrCreate(topKey).addDependency(midKey).setComputedValue(CONCATENATE);
+    tester.getOrCreate(midKey).addDependency(badKey).setComputedValue(CONCATENATE);
+    tester.getOrCreate(badKey).setHasError(true);
+    EvaluationResult<SkyValue> result = eval(/*keepGoing=*/false, topKey, midKey);
+    assertThat(result.getError(midKey).getRootCauses()).containsExactly(badKey);
+    // Do it again with keepGoing.  We should also see an error for the top key this time.
+    result = eval(/*keepGoing=*/true, topKey, midKey);
+    assertThat(result.getError(midKey).getRootCauses()).containsExactly(badKey);
+    assertThat(result.getError(topKey).getRootCauses()).containsExactly(badKey);
+  }
+
+  @Test
+  public void valueNotUsedInFailFastErrorRecovery() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    SkyKey recoveryKey = GraphTester.toSkyKey("midRecovery");
+    SkyKey badKey = GraphTester.toSkyKey("bad");
+
+    tester.getOrCreate(topKey).addDependency(recoveryKey).setComputedValue(CONCATENATE);
+    tester.getOrCreate(recoveryKey).addErrorDependency(badKey, new StringValue("i recovered"))
+        .setComputedValue(CONCATENATE);
+    tester.getOrCreate(badKey).setHasError(true);
+
+    EvaluationResult<SkyValue> result = eval(/*keepGoing=*/true, ImmutableList.of(recoveryKey));
+    assertThat(result.errorMap()).isEmpty();
+    assertTrue(result.hasError());
+    assertEquals(new StringValue("i recovered"), result.get(recoveryKey));
+
+    result = eval(/*keepGoing=*/false, ImmutableList.of(topKey));
+    assertTrue(result.hasError());
+    assertThat(result.keyNames()).isEmpty();
+    assertEquals(1, result.errorMap().size());
+    assertNotNull(result.getError(topKey).getException());
+  }
+
+  /**
+   * Regression test: "clearing incomplete values on --keep_going build is racy".
+   * Tests that if a value is requested on the first (non-keep-going) build and its child throws
+   * an error, when the second (keep-going) build runs, there is not a race that keeps it as a
+   * reverse dep of its children.
+   */
+  @Test
+  public void raceClearingIncompleteValues() throws Exception {
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    final SkyKey midKey = GraphTester.toSkyKey("mid");
+    SkyKey badKey = GraphTester.toSkyKey("bad");
+    final AtomicBoolean waitForSecondCall = new AtomicBoolean(false);
+    final TrackingAwaiter trackingAwaiter = new TrackingAwaiter();
+    final CountDownLatch otherThreadWinning = new CountDownLatch(1);
+    final AtomicReference<Thread> firstThread = new AtomicReference<>();
+    graph = new NotifyingInMemoryGraph(new Listener() {
+      @Override
+      public void accept(SkyKey key, EventType type, Order order, Object context) {
+        if (!waitForSecondCall.get()) {
+          return;
+        }
+        if (key.equals(midKey)) {
+          if (type == EventType.CREATE_IF_ABSENT) {
+            // The first thread to create midKey will not be the first thread to add a reverse dep
+            // to it.
+            firstThread.compareAndSet(null, Thread.currentThread());
+            return;
+          }
+          if (type == EventType.ADD_REVERSE_DEP) {
+            if (order == Order.BEFORE && Thread.currentThread().equals(firstThread.get())) {
+              // If this thread created midKey, block until the other thread adds a dep on it.
+              trackingAwaiter.awaitLatchAndTrackExceptions(otherThreadWinning,
+                  "other thread didn't pass this one");
+            } else if (order == Order.AFTER && !Thread.currentThread().equals(firstThread.get())) {
+              // This thread has added a dep. Allow the other thread to proceed.
+              otherThreadWinning.countDown();
+            }
+          }
+        }
+      }
+    });
+    tester.getOrCreate(topKey).addDependency(midKey).setComputedValue(CONCATENATE);
+    tester.getOrCreate(midKey).addDependency(badKey).setComputedValue(CONCATENATE);
+    tester.getOrCreate(badKey).setHasError(true);
+    EvaluationResult<SkyValue> result = eval(/*keepGoing=*/false, topKey, midKey);
+    assertThat(result.getError(midKey).getRootCauses()).containsExactly(badKey);
+    waitForSecondCall.set(true);
+    result = eval(/*keepGoing=*/true, topKey, midKey);
+    trackingAwaiter.assertNoErrors();
+    assertNotNull(firstThread.get());
+    assertEquals(0, otherThreadWinning.getCount());
+    assertThat(result.getError(midKey).getRootCauses()).containsExactly(badKey);
+    assertThat(result.getError(topKey).getRootCauses()).containsExactly(badKey);
+  }
+
+  @Test
+  public void multipleRootCauses() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    SkyKey errorKey = GraphTester.toSkyKey("error");
+    SkyKey errorKey2 = GraphTester.toSkyKey("error2");
+    SkyKey errorKey3 = GraphTester.toSkyKey("error3");
+    tester.getOrCreate(errorKey).setHasError(true);
+    tester.getOrCreate(errorKey2).setHasError(true);
+    tester.getOrCreate(errorKey3).setHasError(true);
+    tester.getOrCreate("mid").addDependency(errorKey).addDependency(errorKey2)
+      .setComputedValue(CONCATENATE);
+    tester.getOrCreate(parentKey)
+      .addDependency("mid").addDependency(errorKey2).addDependency(errorKey3)
+      .setComputedValue(CONCATENATE);
+    ErrorInfo error = evalValueInError(parentKey);
+    assertThat(error.getRootCauses()).containsExactly(errorKey, errorKey2, errorKey3);
+  }
+
+  @Test
+  public void rootCauseWithNoKeepGoing() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    SkyKey errorKey = GraphTester.toSkyKey("error");
+    tester.getOrCreate(errorKey).setHasError(true);
+    tester.getOrCreate("mid").addDependency(errorKey).setComputedValue(CONCATENATE);
+    tester.getOrCreate(parentKey).addDependency("mid").setComputedValue(CONCATENATE);
+    EvaluationResult<StringValue> result = eval(false, ImmutableList.of(parentKey));
+    Map.Entry<SkyKey, ErrorInfo> error = Iterables.getOnlyElement(result.errorMap().entrySet());
+    assertEquals(parentKey, error.getKey());
+    assertThat(error.getValue().getRootCauses()).containsExactly(errorKey);
+  }
+
+  @Test
+  public void errorBubblesToParentsOfTopLevelValue() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    final SkyKey errorKey = GraphTester.toSkyKey("error");
+    final CountDownLatch latch = new CountDownLatch(1);
+    tester.getOrCreate(errorKey).setBuilder(new ChainedFunction(null, /*waitToFinish=*/latch, null,
+        false, /*value=*/null, ImmutableList.<SkyKey>of()));
+    tester.getOrCreate(parentKey).setBuilder(new ChainedFunction(/*notifyStart=*/latch, null, null,
+        false, new StringValue("unused"), ImmutableList.of(errorKey)));
+    EvaluationResult<StringValue> result = eval( /*keepGoing=*/false,
+        ImmutableList.of(parentKey, errorKey));
+    assertEquals(result.toString(), 2, result.errorMap().size());
+  }
+
+  @Test
+  public void noKeepGoingAfterKeepGoingFails() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    tester.getOrCreate(errorKey).setHasError(true);
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    tester.getOrCreate(parentKey).addDependency(errorKey);
+    ErrorInfo error = evalValueInError(parentKey);
+    assertThat(error.getRootCauses()).containsExactly(errorKey);
+    SkyKey[] list = { parentKey };
+    EvaluationResult<StringValue> result = eval(false, list);
+    ErrorInfo errorInfo = result.getError();
+    assertEquals(errorKey, Iterables.getOnlyElement(errorInfo.getRootCauses()));
+    assertEquals(errorKey.toString(), errorInfo.getException().getMessage());
+  }
+
+  @Test
+  public void twoErrors() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey firstError = GraphTester.toSkyKey("error1");
+    SkyKey secondError = GraphTester.toSkyKey("error2");
+    CountDownLatch firstStart = new CountDownLatch(1);
+    CountDownLatch secondStart = new CountDownLatch(1);
+    tester.getOrCreate(firstError).setBuilder(new ChainedFunction(firstStart, secondStart,
+        /*notifyFinish=*/null, /*waitForException=*/false, /*value=*/null,
+        ImmutableList.<SkyKey>of()));
+    tester.getOrCreate(secondError).setBuilder(new ChainedFunction(secondStart, firstStart,
+        /*notifyFinish=*/null, /*waitForException=*/false, /*value=*/null,
+        ImmutableList.<SkyKey>of()));
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/false, firstError, secondError);
+    assertTrue(result.toString(), result.hasError());
+    // With keepGoing=false, the eval call will terminate with exactly one error (the first one
+    // thrown). But the first one thrown here is non-deterministic since we synchronize the
+    // builders so that they run at roughly the same time.
+    assertThat(ImmutableSet.of(firstError, secondError)).contains(
+        Iterables.getOnlyElement(result.errorMap().keySet()));
+  }
+
+  @Test
+  public void simpleCycle() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey aKey = GraphTester.toSkyKey("a");
+    SkyKey bKey = GraphTester.toSkyKey("b");
+    tester.getOrCreate(aKey).addDependency(bKey);
+    tester.getOrCreate(bKey).addDependency(aKey);
+    ErrorInfo errorInfo = eval(false, ImmutableList.of(aKey)).getError();
+    assertEquals(null, errorInfo.getException());
+    CycleInfo cycleInfo = Iterables.getOnlyElement(errorInfo.getCycleInfo());
+    assertThat(cycleInfo.getCycle()).containsExactly(aKey, bKey).inOrder();
+    assertTrue(cycleInfo.getPathToCycle().isEmpty());
+  }
+
+  @Test
+  public void cycleWithHead() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey aKey = GraphTester.toSkyKey("a");
+    SkyKey bKey = GraphTester.toSkyKey("b");
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    SkyKey midKey = GraphTester.toSkyKey("mid");
+    tester.getOrCreate(topKey).addDependency(midKey);
+    tester.getOrCreate(midKey).addDependency(aKey);
+    tester.getOrCreate(aKey).addDependency(bKey);
+    tester.getOrCreate(bKey).addDependency(aKey);
+    ErrorInfo errorInfo = eval(false, ImmutableList.of(topKey)).getError();
+    assertEquals(null, errorInfo.getException());
+    CycleInfo cycleInfo = Iterables.getOnlyElement(errorInfo.getCycleInfo());
+    assertThat(cycleInfo.getCycle()).containsExactly(aKey, bKey).inOrder();
+    assertThat(cycleInfo.getPathToCycle()).containsExactly(topKey, midKey).inOrder();
+  }
+
+  @Test
+  public void selfEdgeWithHead() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey aKey = GraphTester.toSkyKey("a");
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    SkyKey midKey = GraphTester.toSkyKey("mid");
+    tester.getOrCreate(topKey).addDependency(midKey);
+    tester.getOrCreate(midKey).addDependency(aKey);
+    tester.getOrCreate(aKey).addDependency(aKey);
+    ErrorInfo errorInfo = eval(false, ImmutableList.of(topKey)).getError();
+    assertEquals(null, errorInfo.getException());
+    CycleInfo cycleInfo = Iterables.getOnlyElement(errorInfo.getCycleInfo());
+    assertThat(cycleInfo.getCycle()).containsExactly(aKey).inOrder();
+    assertThat(cycleInfo.getPathToCycle()).containsExactly(topKey, midKey).inOrder();
+  }
+
+  @Test
+  public void cycleWithKeepGoing() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey aKey = GraphTester.toSkyKey("a");
+    SkyKey bKey = GraphTester.toSkyKey("b");
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    SkyKey midKey = GraphTester.toSkyKey("mid");
+    SkyKey goodKey = GraphTester.toSkyKey("good");
+    StringValue goodValue = new StringValue("good");
+    tester.set(goodKey, goodValue);
+    tester.getOrCreate(topKey).addDependency(midKey);
+    tester.getOrCreate(midKey).addDependency(aKey);
+    tester.getOrCreate(aKey).addDependency(bKey);
+    tester.getOrCreate(bKey).addDependency(aKey);
+    EvaluationResult<StringValue> result = eval(true, topKey, goodKey);
+    assertEquals(goodValue, result.get(goodKey));
+    assertEquals(null, result.get(topKey));
+    ErrorInfo errorInfo = result.getError(topKey);
+    CycleInfo cycleInfo = Iterables.getOnlyElement(errorInfo.getCycleInfo());
+    assertThat(cycleInfo.getCycle()).containsExactly(aKey, bKey).inOrder();
+    assertThat(cycleInfo.getPathToCycle()).containsExactly(topKey, midKey).inOrder();
+  }
+
+  @Test
+  public void twoCycles() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey aKey = GraphTester.toSkyKey("a");
+    SkyKey bKey = GraphTester.toSkyKey("b");
+    SkyKey cKey = GraphTester.toSkyKey("c");
+    SkyKey dKey = GraphTester.toSkyKey("d");
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.getOrCreate(topKey).addDependency(aKey).addDependency(cKey);
+    tester.getOrCreate(aKey).addDependency(bKey);
+    tester.getOrCreate(bKey).addDependency(aKey);
+    tester.getOrCreate(cKey).addDependency(dKey);
+    tester.getOrCreate(dKey).addDependency(cKey);
+    EvaluationResult<StringValue> result = eval(false, ImmutableList.of(topKey));
+    assertEquals(null, result.get(topKey));
+    ErrorInfo errorInfo = result.getError(topKey);
+    Iterable<CycleInfo> cycles = CycleInfo.prepareCycles(topKey,
+        ImmutableList.of(new CycleInfo(ImmutableList.of(aKey, bKey)),
+        new CycleInfo(ImmutableList.of(cKey, dKey))));
+    assertThat(cycles).contains(getOnlyElement(errorInfo.getCycleInfo()));
+  }
+
+
+  @Test
+  public void twoCyclesKeepGoing() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey aKey = GraphTester.toSkyKey("a");
+    SkyKey bKey = GraphTester.toSkyKey("b");
+    SkyKey cKey = GraphTester.toSkyKey("c");
+    SkyKey dKey = GraphTester.toSkyKey("d");
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.getOrCreate(topKey).addDependency(aKey).addDependency(cKey);
+    tester.getOrCreate(aKey).addDependency(bKey);
+    tester.getOrCreate(bKey).addDependency(aKey);
+    tester.getOrCreate(cKey).addDependency(dKey);
+    tester.getOrCreate(dKey).addDependency(cKey);
+    EvaluationResult<StringValue> result = eval(true, ImmutableList.of(topKey));
+    assertEquals(null, result.get(topKey));
+    ErrorInfo errorInfo = result.getError(topKey);
+    CycleInfo aCycle = new CycleInfo(ImmutableList.of(topKey), ImmutableList.of(aKey, bKey));
+    CycleInfo cCycle = new CycleInfo(ImmutableList.of(topKey), ImmutableList.of(cKey, dKey));
+    assertThat(errorInfo.getCycleInfo()).containsExactly(aCycle, cCycle);
+  }
+
+  @Test
+  public void triangleBelowHeadCycle() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey aKey = GraphTester.toSkyKey("a");
+    SkyKey bKey = GraphTester.toSkyKey("b");
+    SkyKey cKey = GraphTester.toSkyKey("c");
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.getOrCreate(topKey).addDependency(aKey);
+    tester.getOrCreate(aKey).addDependency(bKey).addDependency(cKey);
+    tester.getOrCreate(bKey).addDependency(cKey);
+    tester.getOrCreate(cKey).addDependency(topKey);
+    EvaluationResult<StringValue> result = eval(true, ImmutableList.of(topKey));
+    assertEquals(null, result.get(topKey));
+    ErrorInfo errorInfo = result.getError(topKey);
+    CycleInfo topCycle = new CycleInfo(ImmutableList.of(topKey, aKey, cKey));
+    assertThat(errorInfo.getCycleInfo()).containsExactly(topCycle);
+  }
+
+  @Test
+  public void longCycle() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey aKey = GraphTester.toSkyKey("a");
+    SkyKey bKey = GraphTester.toSkyKey("b");
+    SkyKey cKey = GraphTester.toSkyKey("c");
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.getOrCreate(topKey).addDependency(aKey);
+    tester.getOrCreate(aKey).addDependency(bKey);
+    tester.getOrCreate(bKey).addDependency(cKey);
+    tester.getOrCreate(cKey).addDependency(topKey);
+    EvaluationResult<StringValue> result = eval(true, ImmutableList.of(topKey));
+    assertEquals(null, result.get(topKey));
+    ErrorInfo errorInfo = result.getError(topKey);
+    CycleInfo topCycle = new CycleInfo(ImmutableList.of(topKey, aKey, bKey, cKey));
+    assertThat(errorInfo.getCycleInfo()).containsExactly(topCycle);
+  }
+
+  @Test
+  public void cycleWithTail() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey aKey = GraphTester.toSkyKey("a");
+    SkyKey bKey = GraphTester.toSkyKey("b");
+    SkyKey cKey = GraphTester.toSkyKey("c");
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.getOrCreate(topKey).addDependency(aKey).addDependency(cKey);
+    tester.getOrCreate(aKey).addDependency(bKey);
+    tester.getOrCreate(bKey).addDependency(aKey).addDependency(cKey);
+    tester.getOrCreate(cKey);
+    tester.set(cKey, new StringValue("cValue"));
+    EvaluationResult<StringValue> result = eval(false, ImmutableList.of(topKey));
+    assertEquals(null, result.get(topKey));
+    ErrorInfo errorInfo = result.getError(topKey);
+    CycleInfo cycleInfo = Iterables.getOnlyElement(errorInfo.getCycleInfo());
+    assertThat(cycleInfo.getCycle()).containsExactly(aKey, bKey).inOrder();
+    assertThat(cycleInfo.getPathToCycle()).containsExactly(topKey).inOrder();
+  }
+
+  /** Regression test: "value cannot be ready in a cycle". */
+  @Test
+  public void selfEdgeWithExtraChildrenUnderCycle() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey aKey = GraphTester.toSkyKey("a");
+    SkyKey bKey = GraphTester.toSkyKey("b");
+    SkyKey cKey = GraphTester.toSkyKey("c");
+    tester.getOrCreate(aKey).addDependency(bKey);
+    tester.getOrCreate(bKey).addDependency(cKey).addDependency(bKey);
+    tester.getOrCreate(cKey).addDependency(aKey);
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/true, ImmutableList.of(aKey));
+    assertEquals(null, result.get(aKey));
+    ErrorInfo errorInfo = result.getError(aKey);
+    CycleInfo cycleInfo = Iterables.getOnlyElement(errorInfo.getCycleInfo());
+    assertThat(cycleInfo.getCycle()).containsExactly(bKey).inOrder();
+    assertThat(cycleInfo.getPathToCycle()).containsExactly(aKey).inOrder();
+  }
+
+  /** Regression test: "value cannot be ready in a cycle". */
+  @Test
+  public void cycleWithExtraChildrenUnderCycle() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey aKey = GraphTester.toSkyKey("a");
+    SkyKey bKey = GraphTester.toSkyKey("b");
+    SkyKey cKey = GraphTester.toSkyKey("c");
+    SkyKey dKey = GraphTester.toSkyKey("d");
+    tester.getOrCreate(aKey).addDependency(bKey);
+    tester.getOrCreate(bKey).addDependency(cKey).addDependency(dKey);
+    tester.getOrCreate(cKey).addDependency(aKey);
+    tester.getOrCreate(dKey).addDependency(bKey);
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/true, ImmutableList.of(aKey));
+    assertEquals(null, result.get(aKey));
+    ErrorInfo errorInfo = result.getError(aKey);
+    CycleInfo cycleInfo = Iterables.getOnlyElement(errorInfo.getCycleInfo());
+    assertThat(cycleInfo.getCycle()).containsExactly(bKey, dKey).inOrder();
+    assertThat(cycleInfo.getPathToCycle()).containsExactly(aKey).inOrder();
+  }
+
+  /** Regression test: "value cannot be ready in a cycle". */
+  @Test
+  public void cycleAboveIndependentCycle() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey aKey = GraphTester.toSkyKey("a");
+    SkyKey bKey = GraphTester.toSkyKey("b");
+    SkyKey cKey = GraphTester.toSkyKey("c");
+    tester.getOrCreate(aKey).addDependency(bKey);
+    tester.getOrCreate(bKey).addDependency(cKey);
+    tester.getOrCreate(cKey).addDependency(aKey).addDependency(bKey);
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/true, ImmutableList.of(aKey));
+    assertEquals(null, result.get(aKey));
+    assertThat(result.getError(aKey).getCycleInfo()).containsExactly(
+        new CycleInfo(ImmutableList.of(aKey, bKey, cKey)),
+        new CycleInfo(ImmutableList.of(aKey), ImmutableList.of(bKey, cKey)));
+  }
+
+  public void valueAboveCycleAndExceptionReportsException() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey aKey = GraphTester.toSkyKey("a");
+    SkyKey errorKey = GraphTester.toSkyKey("error");
+    SkyKey bKey = GraphTester.toSkyKey("b");
+    tester.getOrCreate(aKey).addDependency(bKey).addDependency(errorKey);
+    tester.getOrCreate(bKey).addDependency(bKey);
+    tester.getOrCreate(errorKey).setHasError(true);
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/true, ImmutableList.of(aKey));
+    assertEquals(null, result.get(aKey));
+    assertNotNull(result.getError(aKey).getException());
+    CycleInfo cycleInfo = Iterables.getOnlyElement(result.getError(aKey).getCycleInfo());
+    assertThat(cycleInfo.getCycle()).containsExactly(bKey).inOrder();
+    assertThat(cycleInfo.getPathToCycle()).containsExactly(aKey).inOrder();
+  }
+
+  @Test
+  public void errorValueStored() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    tester.getOrCreate(errorKey).setHasError(true);
+    EvaluationResult<StringValue> result = eval(false, ImmutableList.of(errorKey));
+    assertThat(result.keyNames()).isEmpty();
+    assertThat(result.errorMap().keySet()).containsExactly(errorKey);
+    ErrorInfo errorInfo = result.getError();
+    assertThat(errorInfo.getRootCauses()).containsExactly(errorKey);
+    // Update value. But builder won't rebuild it.
+    tester.getOrCreate(errorKey).setHasError(false);
+    tester.set(errorKey, new StringValue("no error?"));
+    result = eval(false, ImmutableList.of(errorKey));
+    assertThat(result.keyNames()).isEmpty();
+    assertThat(result.errorMap().keySet()).containsExactly(errorKey);
+    errorInfo = result.getError();
+    assertThat(errorInfo.getRootCauses()).containsExactly(errorKey);
+  }
+
+  /**
+   * Regression test: "OOM in Skyframe cycle detection".
+   * We only store the first 20 cycles found below any given root value.
+   */
+  @Test
+  public void manyCycles() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    for (int i = 0; i < 100; i++) {
+      SkyKey dep = GraphTester.toSkyKey(Integer.toString(i));
+      tester.getOrCreate(topKey).addDependency(dep);
+      tester.getOrCreate(dep).addDependency(dep);
+    }
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/true, ImmutableList.of(topKey));
+    assertEquals(null, result.get(topKey));
+    assertManyCycles(result.getError(topKey), topKey, /*selfEdge=*/false);
+  }
+
+  /**
+   * Regression test: "OOM in Skyframe cycle detection".
+   * We filter out multiple paths to a cycle that go through the same child value.
+   */
+  @Test
+  public void manyPathsToCycle() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    SkyKey midKey = GraphTester.toSkyKey("mid");
+    SkyKey cycleKey = GraphTester.toSkyKey("cycle");
+    tester.getOrCreate(topKey).addDependency(midKey);
+    tester.getOrCreate(cycleKey).addDependency(cycleKey);
+    for (int i = 0; i < 100; i++) {
+      SkyKey dep = GraphTester.toSkyKey(Integer.toString(i));
+      tester.getOrCreate(midKey).addDependency(dep);
+      tester.getOrCreate(dep).addDependency(cycleKey);
+    }
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/true, ImmutableList.of(topKey));
+    assertEquals(null, result.get(topKey));
+    CycleInfo cycleInfo = Iterables.getOnlyElement(result.getError(topKey).getCycleInfo());
+    assertEquals(1, cycleInfo.getCycle().size());
+    assertEquals(3, cycleInfo.getPathToCycle().size());
+    assertThat(cycleInfo.getPathToCycle().subList(0, 2)).containsExactly(topKey, midKey).inOrder();
+  }
+
+  /**
+   * Checks that errorInfo has many self-edge cycles, and that one of them is a self-edge of
+   * topKey, if {@code selfEdge} is true.
+   */
+  private static void assertManyCycles(ErrorInfo errorInfo, SkyKey topKey, boolean selfEdge) {
+    MoreAsserts.assertGreaterThan(1, Iterables.size(errorInfo.getCycleInfo()));
+    MoreAsserts.assertLessThan(50, Iterables.size(errorInfo.getCycleInfo()));
+    boolean foundSelfEdge = false;
+    for (CycleInfo cycle : errorInfo.getCycleInfo()) {
+      assertEquals(1, cycle.getCycle().size()); // Self-edge.
+      if (!Iterables.isEmpty(cycle.getPathToCycle())) {
+        assertThat(cycle.getPathToCycle()).containsExactly(topKey).inOrder();
+      } else {
+        assertThat(cycle.getCycle()).containsExactly(topKey).inOrder();
+        foundSelfEdge = true;
+      }
+    }
+    assertEquals(errorInfo + ", " + topKey, selfEdge, foundSelfEdge);
+  }
+
+  @Test
+  public void manyUnprocessedValuesInCycle() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey lastSelfKey = GraphTester.toSkyKey("lastSelf");
+    SkyKey firstSelfKey = GraphTester.toSkyKey("firstSelf");
+    SkyKey midSelfKey = GraphTester.toSkyKey("midSelf");
+    // We add firstSelf first so that it is processed last in cycle detection (LIFO), meaning that
+    // none of the dep values have to be cleared from firstSelf.
+    tester.getOrCreate(firstSelfKey).addDependency(firstSelfKey);
+    for (int i = 0; i < 100; i++) {
+      SkyKey firstDep = GraphTester.toSkyKey("first" + i);
+      SkyKey midDep = GraphTester.toSkyKey("mid" + i);
+      SkyKey lastDep = GraphTester.toSkyKey("last" + i);
+      tester.getOrCreate(firstSelfKey).addDependency(firstDep);
+      tester.getOrCreate(midSelfKey).addDependency(midDep);
+      tester.getOrCreate(lastSelfKey).addDependency(lastDep);
+      if (i == 90) {
+        // Most of the deps will be cleared from midSelf.
+        tester.getOrCreate(midSelfKey).addDependency(midSelfKey);
+      }
+      tester.getOrCreate(firstDep).addDependency(firstDep);
+      tester.getOrCreate(midDep).addDependency(midDep);
+      tester.getOrCreate(lastDep).addDependency(lastDep);
+    }
+    // All the deps will be cleared from lastSelf.
+    tester.getOrCreate(lastSelfKey).addDependency(lastSelfKey);
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/true,
+        ImmutableList.of(lastSelfKey, firstSelfKey, midSelfKey));
+    assertWithMessage(result.toString()).that(result.keyNames()).isEmpty();
+    assertThat(result.errorMap().keySet()).containsExactly(lastSelfKey, firstSelfKey, midSelfKey);
+
+    // Check lastSelfKey.
+    ErrorInfo errorInfo = result.getError(lastSelfKey);
+    assertEquals(errorInfo.toString(), 1, Iterables.size(errorInfo.getCycleInfo()));
+    CycleInfo cycleInfo = Iterables.getOnlyElement(errorInfo.getCycleInfo());
+    assertThat(cycleInfo.getCycle()).containsExactly(lastSelfKey);
+    assertThat(cycleInfo.getPathToCycle()).isEmpty();
+
+    // Check firstSelfKey. It should not have discovered its own self-edge, because there were too
+    // many other values before it in the queue.
+    assertManyCycles(result.getError(firstSelfKey), firstSelfKey, /*selfEdge=*/false);
+
+    // Check midSelfKey. It should have discovered its own self-edge.
+    assertManyCycles(result.getError(midSelfKey), midSelfKey, /*selfEdge=*/true);
+  }
+
+  @Test
+  public void errorValueStoredWithKeepGoing() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    tester.getOrCreate(errorKey).setHasError(true);
+    EvaluationResult<StringValue> result = eval(true, ImmutableList.of(errorKey));
+    assertThat(result.keyNames()).isEmpty();
+    assertThat(result.errorMap().keySet()).containsExactly(errorKey);
+    ErrorInfo errorInfo = result.getError();
+    assertThat(errorInfo.getRootCauses()).containsExactly(errorKey);
+    // Update value. But builder won't rebuild it.
+    tester.getOrCreate(errorKey).setHasError(false);
+    tester.set(errorKey, new StringValue("no error?"));
+    result = eval(true, ImmutableList.of(errorKey));
+    assertThat(result.keyNames()).isEmpty();
+    assertThat(result.errorMap().keySet()).containsExactly(errorKey);
+    errorInfo = result.getError();
+    assertThat(errorInfo.getRootCauses()).containsExactly(errorKey);
+  }
+
+  @Test
+  public void continueWithErrorDep() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    tester.getOrCreate(errorKey).setHasError(true);
+    tester.set("after", new StringValue("after"));
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    tester.getOrCreate(parentKey).addErrorDependency(errorKey, new StringValue("recovered"))
+        .setComputedValue(CONCATENATE).addDependency("after");
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/true, ImmutableList.of(parentKey));
+    assertThat(result.errorMap()).isEmpty();
+    assertEquals("recoveredafter", result.get(parentKey).getValue());
+    result = eval(/*keepGoing=*/false, ImmutableList.of(parentKey));
+    assertThat(result.keyNames()).isEmpty();
+    Map.Entry<SkyKey, ErrorInfo> error = Iterables.getOnlyElement(result.errorMap().entrySet());
+    assertEquals(parentKey, error.getKey());
+    assertThat(error.getValue().getRootCauses()).containsExactly(errorKey);
+  }
+
+  @Test
+  public void breakWithErrorDep() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    tester.getOrCreate(errorKey).setHasError(true);
+    tester.set("after", new StringValue("after"));
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    tester.getOrCreate(parentKey).addErrorDependency(errorKey, new StringValue("recovered"))
+        .setComputedValue(CONCATENATE).addDependency("after");
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/false, ImmutableList.of(parentKey));
+    assertThat(result.keyNames()).isEmpty();
+    Map.Entry<SkyKey, ErrorInfo> error = Iterables.getOnlyElement(result.errorMap().entrySet());
+    assertEquals(parentKey, error.getKey());
+    assertThat(error.getValue().getRootCauses()).containsExactly(errorKey);
+    result = eval(/*keepGoing=*/true, ImmutableList.of(parentKey));
+    assertThat(result.errorMap()).isEmpty();
+    assertEquals("recoveredafter", result.get(parentKey).getValue());
+  }
+
+  @Test
+  public void breakWithInterruptibleErrorDep() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    tester.getOrCreate(errorKey).setHasError(true);
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    tester.getOrCreate(parentKey).addErrorDependency(errorKey, new StringValue("recovered"))
+        .setComputedValue(CONCATENATE);
+    // When the error value throws, the propagation will cause an interrupted exception in parent.
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/false, ImmutableList.of(parentKey));
+    assertThat(result.keyNames()).isEmpty();
+    Map.Entry<SkyKey, ErrorInfo> error = Iterables.getOnlyElement(result.errorMap().entrySet());
+    assertEquals(parentKey, error.getKey());
+    assertThat(error.getValue().getRootCauses()).containsExactly(errorKey);
+    assertFalse(Thread.interrupted());
+    result = eval(/*keepGoing=*/true, ImmutableList.of(parentKey));
+    assertThat(result.errorMap()).isEmpty();
+    assertEquals("recovered", result.get(parentKey).getValue());
+  }
+
+  @Test
+  public void transformErrorDep() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    tester.getOrCreate(errorKey).setHasError(true);
+    SkyKey parentErrorKey = GraphTester.toSkyKey("parent");
+    tester.getOrCreate(parentErrorKey).addErrorDependency(errorKey, new StringValue("recovered"))
+        .setHasError(true);
+    EvaluationResult<StringValue> result = eval(
+        /*keepGoing=*/false, ImmutableList.of(parentErrorKey));
+    assertThat(result.keyNames()).isEmpty();
+    Map.Entry<SkyKey, ErrorInfo> error = Iterables.getOnlyElement(result.errorMap().entrySet());
+    assertEquals(parentErrorKey, error.getKey());
+    assertThat(error.getValue().getRootCauses()).containsExactly(parentErrorKey);
+  }
+
+  @Test
+  public void transformErrorDepKeepGoing() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    tester.getOrCreate(errorKey).setHasError(true);
+    SkyKey parentErrorKey = GraphTester.toSkyKey("parent");
+    tester.getOrCreate(parentErrorKey).addErrorDependency(errorKey, new StringValue("recovered"))
+        .setHasError(true);
+    EvaluationResult<StringValue> result = eval(
+        /*keepGoing=*/true, ImmutableList.of(parentErrorKey));
+    assertThat(result.keyNames()).isEmpty();
+    Map.Entry<SkyKey, ErrorInfo> error = Iterables.getOnlyElement(result.errorMap().entrySet());
+    assertEquals(parentErrorKey, error.getKey());
+    assertThat(error.getValue().getRootCauses()).containsExactly(parentErrorKey);
+  }
+
+  @Test
+  public void transformErrorDepOneLevelDownKeepGoing() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    tester.getOrCreate(errorKey).setHasError(true);
+    tester.set("after", new StringValue("after"));
+    SkyKey parentErrorKey = GraphTester.toSkyKey("parent");
+    tester.getOrCreate(parentErrorKey).addErrorDependency(errorKey, new StringValue("recovered"));
+    tester.set(parentErrorKey, new StringValue("parent value"));
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.getOrCreate(topKey).addDependency(parentErrorKey).addDependency("after")
+        .setComputedValue(CONCATENATE);
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/true, ImmutableList.of(topKey));
+    assertThat(ImmutableList.<String>copyOf(result.<String>keyNames())).containsExactly("top");
+    assertEquals("parent valueafter", result.get(topKey).getValue());
+    assertThat(result.errorMap()).isEmpty();
+  }
+
+  @Test
+  public void transformErrorDepOneLevelDownNoKeepGoing() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    tester.getOrCreate(errorKey).setHasError(true);
+    tester.set("after", new StringValue("after"));
+    SkyKey parentErrorKey = GraphTester.toSkyKey("parent");
+    tester.getOrCreate(parentErrorKey).addErrorDependency(errorKey, new StringValue("recovered"));
+    tester.set(parentErrorKey, new StringValue("parent value"));
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.getOrCreate(topKey).addDependency(parentErrorKey).addDependency("after")
+        .setComputedValue(CONCATENATE);
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/false, ImmutableList.of(topKey));
+    assertThat(result.keyNames()).isEmpty();
+    Map.Entry<SkyKey, ErrorInfo> error = Iterables.getOnlyElement(result.errorMap().entrySet());
+    assertEquals(topKey, error.getKey());
+    assertThat(error.getValue().getRootCauses()).containsExactly(errorKey);
+  }
+
+  /**
+   * Make sure that multiple unfinished children can be cleared from a cycle value.
+   */
+  @Test
+  public void cycleWithMultipleUnfinishedChildren() throws Exception {
+    graph = new InMemoryGraph();
+    tester = new GraphTester();
+    SkyKey cycleKey = GraphTester.toSkyKey("cycle");
+    SkyKey midKey = GraphTester.toSkyKey("mid");
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    SkyKey selfEdge1 = GraphTester.toSkyKey("selfEdge1");
+    SkyKey selfEdge2 = GraphTester.toSkyKey("selfEdge2");
+    tester.getOrCreate(topKey).addDependency(midKey).setComputedValue(CONCATENATE);
+    // selfEdge* come before cycleKey, so cycleKey's path will be checked first (LIFO), and the
+    // cycle with mid will be detected before the selfEdge* cycles are.
+    tester.getOrCreate(midKey).addDependency(selfEdge1).addDependency(selfEdge2)
+        .addDependency(cycleKey)
+    .setComputedValue(CONCATENATE);
+    tester.getOrCreate(cycleKey).addDependency(midKey);
+    tester.getOrCreate(selfEdge1).addDependency(selfEdge1);
+    tester.getOrCreate(selfEdge2).addDependency(selfEdge2);
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/true, ImmutableSet.of(topKey));
+    assertThat(result.errorMap().keySet()).containsExactly(topKey);
+    Iterable<CycleInfo> cycleInfos = result.getError(topKey).getCycleInfo();
+    CycleInfo cycleInfo = Iterables.getOnlyElement(cycleInfos);
+    assertThat(cycleInfo.getPathToCycle()).containsExactly(topKey);
+    assertThat(cycleInfo.getCycle()).containsExactly(midKey, cycleKey);
+  }
+
+  /**
+   * Regression test: "value in cycle depends on error".
+   * The mid value will have two parents -- top and cycle. Error bubbles up from mid to cycle, and
+   * we should detect cycle.
+   */
+  private void cycleAndErrorInBubbleUp(boolean keepGoing) throws Exception {
+    graph = new DeterministicInMemoryGraph();
+    tester = new GraphTester();
+    SkyKey errorKey = GraphTester.toSkyKey("error");
+    SkyKey cycleKey = GraphTester.toSkyKey("cycle");
+    SkyKey midKey = GraphTester.toSkyKey("mid");
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.getOrCreate(topKey).addDependency(midKey).setComputedValue(CONCATENATE);
+    tester.getOrCreate(midKey).addDependency(errorKey).addDependency(cycleKey)
+        .setComputedValue(CONCATENATE);
+
+    // We need to ensure that cycle value has finished his work, and we have recorded dependencies
+    CountDownLatch cycleFinish = new CountDownLatch(1);
+    tester.getOrCreate(cycleKey).setBuilder(new ChainedFunction(null,
+        null, cycleFinish, false, new StringValue(""), ImmutableSet.<SkyKey>of(midKey)));
+    tester.getOrCreate(errorKey).setBuilder(new ChainedFunction(null, cycleFinish,
+        null, /*waitForException=*/false, null, ImmutableSet.<SkyKey>of()));
+
+    EvaluationResult<StringValue> result = eval(keepGoing, ImmutableSet.of(topKey));
+    assertThat(result.errorMap().keySet()).containsExactly(topKey);
+    Iterable<CycleInfo> cycleInfos = result.getError(topKey).getCycleInfo();
+    if (keepGoing) {
+      // The error thrown will only be recorded in keep_going mode.
+      assertThat(result.getError().getRootCauses()).containsExactly(errorKey);
+    }
+    assertThat(cycleInfos).isNotEmpty();
+    CycleInfo cycleInfo = Iterables.getOnlyElement(cycleInfos);
+    assertThat(cycleInfo.getPathToCycle()).containsExactly(topKey);
+    assertThat(cycleInfo.getCycle()).containsExactly(midKey, cycleKey);
+  }
+
+  @Test
+  public void cycleAndErrorInBubbleUpNoKeepGoing() throws Exception {
+    cycleAndErrorInBubbleUp(false);
+  }
+
+  @Test
+  public void cycleAndErrorInBubbleUpKeepGoing() throws Exception {
+    cycleAndErrorInBubbleUp(true);
+  }
+
+  /**
+   * Regression test: "value in cycle depends on error".
+   * We add another value that won't finish building before the threadpool shuts down, to check that
+   * the cycle detection can handle unfinished values.
+   */
+  @Test
+  public void cycleAndErrorAndOtherInBubbleUp() throws Exception {
+    graph = new DeterministicInMemoryGraph();
+    tester = new GraphTester();
+    SkyKey errorKey = GraphTester.toSkyKey("error");
+    SkyKey cycleKey = GraphTester.toSkyKey("cycle");
+    SkyKey midKey = GraphTester.toSkyKey("mid");
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.getOrCreate(topKey).addDependency(midKey).setComputedValue(CONCATENATE);
+    // We should add cycleKey first and errorKey afterwards. Otherwise there is a chance that
+    // during error propagation cycleKey will not be processed, and we will not detect the cycle.
+    tester.getOrCreate(midKey).addDependency(errorKey).addDependency(cycleKey)
+        .setComputedValue(CONCATENATE);
+    SkyKey otherTop = GraphTester.toSkyKey("otherTop");
+    CountDownLatch topStartAndCycleFinish = new CountDownLatch(2);
+    // In nokeep_going mode, otherTop will wait until the threadpool has received an exception,
+    // then request its own dep. This guarantees that there is a value that is not finished when
+    // cycle detection happens.
+    tester.getOrCreate(otherTop).setBuilder(new ChainedFunction(topStartAndCycleFinish,
+        new CountDownLatch(0), null, /*waitForException=*/true, new StringValue("never returned"),
+        ImmutableSet.<SkyKey>of(GraphTester.toSkyKey("dep that never builds"))));
+
+    tester.getOrCreate(cycleKey).setBuilder(new ChainedFunction(null, null,
+        topStartAndCycleFinish, /*waitForException=*/false, new StringValue(""),
+        ImmutableSet.<SkyKey>of(midKey)));
+    // error waits until otherTop starts and cycle finishes, to make sure otherTop will request
+    // its dep before the threadpool shuts down.
+    tester.getOrCreate(errorKey).setBuilder(new ChainedFunction(null, topStartAndCycleFinish,
+        null, /*waitForException=*/false, null,
+        ImmutableSet.<SkyKey>of()));
+    EvaluationResult<StringValue> result =
+        eval(/*keepGoing=*/false, ImmutableSet.of(topKey, otherTop));
+    assertThat(result.errorMap().keySet()).containsExactly(topKey);
+    Iterable<CycleInfo> cycleInfos = result.getError(topKey).getCycleInfo();
+    assertThat(cycleInfos).isNotEmpty();
+    CycleInfo cycleInfo = Iterables.getOnlyElement(cycleInfos);
+    assertThat(cycleInfo.getPathToCycle()).containsExactly(topKey);
+    assertThat(cycleInfo.getCycle()).containsExactly(midKey, cycleKey);
+  }
+
+  /**
+   * Regression test: "value in cycle depends on error".
+   * Here, we add an additional top-level key in error, just to mix it up.
+   */
+  private void cycleAndErrorAndError(boolean keepGoing) throws Exception {
+    graph = new DeterministicInMemoryGraph();
+    tester = new GraphTester();
+    SkyKey errorKey = GraphTester.toSkyKey("error");
+    SkyKey cycleKey = GraphTester.toSkyKey("cycle");
+    SkyKey midKey = GraphTester.toSkyKey("mid");
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.getOrCreate(topKey).addDependency(midKey).setComputedValue(CONCATENATE);
+    tester.getOrCreate(midKey).addDependency(errorKey).addDependency(cycleKey)
+        .setComputedValue(CONCATENATE);
+    SkyKey otherTop = GraphTester.toSkyKey("otherTop");
+    CountDownLatch topStartAndCycleFinish = new CountDownLatch(2);
+    // In nokeep_going mode, otherTop will wait until the threadpool has received an exception,
+    // then throw its own exception. This guarantees that its exception will not be the one
+    // bubbling up, but that there is a top-level value with an exception by the time the bubbling
+    // up starts.
+    tester.getOrCreate(otherTop).setBuilder(new ChainedFunction(topStartAndCycleFinish,
+        new CountDownLatch(0), null, /*waitForException=*/!keepGoing, null,
+        ImmutableSet.<SkyKey>of()));
+    // error waits until otherTop starts and cycle finishes, to make sure otherTop will request
+    // its dep before the threadpool shuts down.
+    tester.getOrCreate(errorKey).setBuilder(new ChainedFunction(null, topStartAndCycleFinish,
+        null, /*waitForException=*/false, null,
+        ImmutableSet.<SkyKey>of()));
+    tester.getOrCreate(cycleKey).setBuilder(new ChainedFunction(null, null,
+        topStartAndCycleFinish, /*waitForException=*/false, new StringValue(""),
+        ImmutableSet.<SkyKey>of(midKey)));
+    EvaluationResult<StringValue> result =
+        eval(keepGoing, ImmutableSet.of(topKey, otherTop));
+    if (keepGoing) {
+      assertThat(result.errorMap().keySet()).containsExactly(otherTop, topKey);
+      assertThat(result.getError(otherTop).getRootCauses()).containsExactly(otherTop);
+      // The error thrown will only be recorded in keep_going mode.
+      assertThat(result.getError(topKey).getRootCauses()).containsExactly(errorKey);
+    }
+    Iterable<CycleInfo> cycleInfos = result.getError(topKey).getCycleInfo();
+    assertThat(cycleInfos).isNotEmpty();
+    CycleInfo cycleInfo = Iterables.getOnlyElement(cycleInfos);
+    assertThat(cycleInfo.getPathToCycle()).containsExactly(topKey);
+    assertThat(cycleInfo.getCycle()).containsExactly(midKey, cycleKey);
+  }
+
+  @Test
+  public void cycleAndErrorAndErrorNoKeepGoing() throws Exception {
+    cycleAndErrorAndError(false);
+  }
+
+  @Test
+  public void cycleAndErrorAndErrorKeepGoing() throws Exception {
+    cycleAndErrorAndError(true);
+  }
+
+  @Test
+  public void testFunctionCrashTrace() throws Exception {
+    final SkyFunctionName childType = new SkyFunctionName("child", false);
+    final SkyFunctionName parentType = new SkyFunctionName("parent", false);
+
+    class ChildFunction implements SkyFunction {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) {
+        throw new IllegalStateException("I WANT A PONY!!!");
+      }
+
+      @Override public String extractTag(SkyKey skyKey) { return null; }
+    }
+
+    class ParentFunction implements SkyFunction {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) {
+        SkyValue dep = env.getValue(new SkyKey(childType, "billy the kid"));
+        if (dep == null) {
+          return null;
+        }
+        throw new IllegalStateException();  // Should never get here.
+      }
+
+      @Override public String extractTag(SkyKey skyKey) { return null; }
+    }
+
+    ImmutableMap<SkyFunctionName, SkyFunction> skyFunctions = ImmutableMap.of(
+        childType, new ChildFunction(),
+        parentType, new ParentFunction());
+    ParallelEvaluator evaluator = makeEvaluator(new InMemoryGraph(),
+        skyFunctions, false);
+
+    try {
+      evaluator.eval(ImmutableList.of(new SkyKey(parentType, "octodad")));
+      fail();
+    } catch (RuntimeException e) {
+      assertEquals("I WANT A PONY!!!", e.getCause().getMessage());
+      assertEquals("Unrecoverable error while evaluating node 'child:billy the kid' "
+          + "(requested by nodes 'parent:octodad')", e.getMessage());
+    }
+  }
+
+  private static class SomeOtherErrorException extends Exception {
+    public SomeOtherErrorException(String msg) {
+      super(msg);
+    }
+  }
+
+  private void unexpectedErrorDep(boolean keepGoing) throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    final SomeOtherErrorException exception = new SomeOtherErrorException("error exception");
+    tester.getOrCreate(errorKey).setBuilder(new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException {
+        throw new SkyFunctionException(exception, Transience.PERSISTENT) {};
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        throw new UnsupportedOperationException();
+      }
+    });
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    tester.getOrCreate(topKey).addErrorDependency(errorKey, new StringValue("recovered"))
+        .setComputedValue(CONCATENATE);
+    EvaluationResult<StringValue> result = eval(keepGoing, ImmutableList.of(topKey));
+    assertThat(result.keyNames()).isEmpty();
+    assertSame(exception, result.getError(topKey).getException());
+    assertThat(result.getError(topKey).getRootCauses()).containsExactly(errorKey);
+  }
+
+  /**
+   * This and the following three tests are in response a bug: "Skyframe error propagation model is
+   * problematic". They ensure that exceptions a child throws that a value does not specify it can
+   * handle in getValueOrThrow do not cause a crash.
+   */
+  @Test
+  public void unexpectedErrorDepKeepGoing() throws Exception {
+    unexpectedErrorDep(true);
+  }
+
+  @Test
+  public void unexpectedErrorDepNoKeepGoing() throws Exception {
+    unexpectedErrorDep(false);
+  }
+
+  private void unexpectedErrorDepOneLevelDown(final boolean keepGoing) throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey errorKey = GraphTester.toSkyKey("my_error_value");
+    final SomeErrorException exception = new SomeErrorException("error exception");
+    final SomeErrorException topException = new SomeErrorException("top exception");
+    final StringValue topValue = new StringValue("top");
+    tester.getOrCreate(errorKey).setBuilder(new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws GenericFunctionException {
+        throw new GenericFunctionException(exception, Transience.PERSISTENT);
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        throw new UnsupportedOperationException();
+      }
+    });
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    final SkyKey parentKey = GraphTester.toSkyKey("parent");
+    tester.getOrCreate(parentKey).addDependency(errorKey).setComputedValue(CONCATENATE);
+    tester.getOrCreate(topKey).setBuilder(new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws GenericFunctionException {
+        try {
+          if (env.getValueOrThrow(parentKey, SomeErrorException.class) == null) {
+            return null;
+          }
+        } catch (SomeErrorException e) {
+          assertEquals(e.toString(), exception, e);
+        }
+        if (keepGoing) {
+          return topValue;
+        } else {
+          throw new GenericFunctionException(topException, Transience.PERSISTENT);
+        }
+      }
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        throw new UnsupportedOperationException();
+      }
+    });
+    tester.getOrCreate(topKey).addErrorDependency(errorKey, new StringValue("recovered"))
+        .setComputedValue(CONCATENATE);
+    EvaluationResult<StringValue> result = eval(keepGoing, ImmutableList.of(topKey));
+    if (!keepGoing) {
+      assertThat(result.keyNames()).isEmpty();
+      assertEquals(topException, result.getError(topKey).getException());
+      assertThat(result.getError(topKey).getRootCauses()).containsExactly(topKey);
+      assertTrue(result.hasError());
+    } else {
+      // result.hasError() is set to true even if the top-level value returned has recovered from
+      // an error.
+      assertTrue(result.hasError());
+      assertSame(topValue, result.get(topKey));
+    }
+  }
+
+  @Test
+  public void unexpectedErrorDepOneLevelDownKeepGoing() throws Exception {
+    unexpectedErrorDepOneLevelDown(true);
+  }
+
+  @Test
+  public void unexpectedErrorDepOneLevelDownNoKeepGoing() throws Exception {
+    unexpectedErrorDepOneLevelDown(false);
+  }
+
+  /**
+   * Exercises various situations involving groups of deps that overlap -- request one group, then
+   * request another group that has a dep in common with the first group.
+   *
+   * @param sameFirst whether the dep in common in the two groups should be the first dep.
+   * @param twoCalls whether the two groups should be requested in two different builder calls.
+   * @param valuesOrThrow whether the deps should be requested using getValuesOrThrow.
+   */
+  private void sameDepInTwoGroups(final boolean sameFirst, final boolean twoCalls,
+      final boolean valuesOrThrow) throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey topKey = GraphTester.toSkyKey("top");
+    final List<SkyKey> leaves = new ArrayList<>();
+    for (int i = 1; i <= 3; i++) {
+      SkyKey leaf = GraphTester.toSkyKey("leaf" + i);
+      leaves.add(leaf);
+      tester.set(leaf, new StringValue("leaf" + i));
+    }
+    final SkyKey leaf4 = GraphTester.toSkyKey("leaf4");
+    tester.set(leaf4, new StringValue("leaf" + 4));
+    tester.getOrCreate(topKey).setBuilder(new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException,
+          InterruptedException {
+        if (valuesOrThrow) {
+          env.getValuesOrThrow(leaves, SomeErrorException.class);
+        } else {
+          env.getValues(leaves);
+        }
+        if (twoCalls && env.valuesMissing()) {
+          return null;
+        }
+        SkyKey first = sameFirst ? leaves.get(0) : leaf4;
+        SkyKey second = sameFirst ? leaf4 : leaves.get(2);
+        List<SkyKey> secondRequest = ImmutableList.of(first, second);
+        if (valuesOrThrow) {
+          env.getValuesOrThrow(secondRequest, SomeErrorException.class);
+        } else {
+          env.getValues(secondRequest);
+        }
+        if (env.valuesMissing()) {
+          return null;
+        }
+        return new StringValue("top");
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        return null;
+      }
+    });
+    eval(/*keepGoing=*/false, topKey);
+    assertEquals(new StringValue("top"), eval(/*keepGoing=*/false, topKey));
+  }
+
+  @Test
+  public void sameDepInTwoGroups_Same_Two_Throw() throws Exception {
+    sameDepInTwoGroups(/*sameFirst=*/true, /*twoCalls=*/true, /*valuesOrThrow=*/true);
+  }
+
+  @Test
+  public void sameDepInTwoGroups_Same_Two_Deps() throws Exception {
+    sameDepInTwoGroups(/*sameFirst=*/true, /*twoCalls=*/true, /*valuesOrThrow=*/false);
+  }
+
+  @Test
+  public void sameDepInTwoGroups_Same_One_Throw() throws Exception {
+    sameDepInTwoGroups(/*sameFirst=*/true, /*twoCalls=*/false, /*valuesOrThrow=*/true);
+  }
+
+  @Test
+  public void sameDepInTwoGroups_Same_One_Deps() throws Exception {
+    sameDepInTwoGroups(/*sameFirst=*/true, /*twoCalls=*/false, /*valuesOrThrow=*/false);
+  }
+
+  @Test
+  public void sameDepInTwoGroups_Different_Two_Throw() throws Exception {
+    sameDepInTwoGroups(/*sameFirst=*/false, /*twoCalls=*/true, /*valuesOrThrow=*/true);
+  }
+
+  @Test
+  public void sameDepInTwoGroups_Different_Two_Deps() throws Exception {
+    sameDepInTwoGroups(/*sameFirst=*/false, /*twoCalls=*/true, /*valuesOrThrow=*/false);
+  }
+
+  @Test
+  public void sameDepInTwoGroups_Different_One_Throw() throws Exception {
+    sameDepInTwoGroups(/*sameFirst=*/false, /*twoCalls=*/false, /*valuesOrThrow=*/true);
+  }
+
+  @Test
+  public void sameDepInTwoGroups_Different_One_Deps() throws Exception {
+    sameDepInTwoGroups(/*sameFirst=*/false, /*twoCalls=*/false, /*valuesOrThrow=*/false);
+  }
+
+  private void getValuesOrThrowWithErrors(boolean keepGoing) throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    final SkyKey errorDep = GraphTester.toSkyKey("errorChild");
+    final SomeErrorException childExn = new SomeErrorException("child error");
+    tester.getOrCreate(errorDep).setBuilder(new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException {
+        throw new GenericFunctionException(childExn, Transience.PERSISTENT);
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        return null;
+      }
+    });
+    final List<SkyKey> deps = new ArrayList<>();
+    for (int i = 1; i <= 3; i++) {
+      SkyKey dep = GraphTester.toSkyKey("child" + i);
+      deps.add(dep);
+      tester.set(dep, new StringValue("child" + i));
+    }
+    final SomeErrorException parentExn = new SomeErrorException("parent error");
+    tester.getOrCreate(parentKey).setBuilder(new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException {
+        try {
+          SkyValue value = env.getValueOrThrow(errorDep, SomeErrorException.class);
+          if (value == null) {
+            return null;
+          }
+        } catch (SomeErrorException e) {
+          // Recover from the child error.
+        }
+        env.getValues(deps);
+        if (env.valuesMissing()) {
+          return null;
+        }
+        throw new GenericFunctionException(parentExn, Transience.PERSISTENT);
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        return null;
+      }
+    });
+    EvaluationResult<StringValue> evaluationResult = eval(keepGoing, ImmutableList.of(parentKey));
+    assertTrue(evaluationResult.hasError());
+    assertEquals(keepGoing ? parentExn : childExn, evaluationResult.getError().getException());
+  }
+
+  @Test
+  public void getValuesOrThrowWithErrors_NoKeepGoing() throws Exception {
+    getValuesOrThrowWithErrors(/*keepGoing=*/false);
+  }
+
+  @Test
+  public void getValuesOrThrowWithErrors_KeepGoing() throws Exception {
+    getValuesOrThrowWithErrors(/*keepGoing=*/true);
+  }
+
+  @Test
+  public void duplicateCycles() throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey grandparentKey = GraphTester.toSkyKey("grandparent");
+    SkyKey parentKey1 = GraphTester.toSkyKey("parent1");
+    SkyKey parentKey2 = GraphTester.toSkyKey("parent2");
+    SkyKey loopKey1 = GraphTester.toSkyKey("loop1");
+    SkyKey loopKey2 = GraphTester.toSkyKey("loop2");
+    tester.getOrCreate(loopKey1).addDependency(loopKey2);
+    tester.getOrCreate(loopKey2).addDependency(loopKey1);
+    tester.getOrCreate(parentKey1).addDependency(loopKey1);
+    tester.getOrCreate(parentKey2).addDependency(loopKey2);
+    tester.getOrCreate(grandparentKey).addDependency(parentKey1);
+    tester.getOrCreate(grandparentKey).addDependency(parentKey2);
+
+    ErrorInfo errorInfo = evalValueInError(grandparentKey);
+    List<ImmutableList<SkyKey>> cycles = Lists.newArrayList();
+    for (CycleInfo cycleInfo : errorInfo.getCycleInfo()) {
+      cycles.add(cycleInfo.getCycle());
+    }
+    // Skyframe doesn't automatically dedupe cycles that are the same except for entry point.
+    assertEquals(2, cycles.size());
+    int numUniqueCycles = 0;
+    CycleDeduper<SkyKey> cycleDeduper = new CycleDeduper<SkyKey>();
+    for (ImmutableList<SkyKey> cycle : cycles) {
+      if (cycleDeduper.seen(cycle)) {
+        numUniqueCycles++;
+      }
+    }
+    assertEquals(1, numUniqueCycles);
+  }
+
+  @Test
+  public void signalValueEnqueuedAndEvaluated() throws Exception {
+    final Set<SkyKey> enqueuedValues = Sets.newConcurrentHashSet();
+    final Set<SkyKey> evaluatedValues = Sets.newConcurrentHashSet();
+    EvaluationProgressReceiver progressReceiver = new EvaluationProgressReceiver() {
+      @Override
+      public void invalidated(SkyValue value, InvalidationState state) {
+        throw new IllegalStateException();
+      }
+
+      @Override
+      public void enqueueing(SkyKey skyKey) {
+        enqueuedValues.add(skyKey);
+      }
+
+      @Override
+      public void evaluated(SkyKey skyKey, SkyValue value, EvaluationState state) {
+        evaluatedValues.add(skyKey);
+      }
+    };
+
+    EventHandler reporter = new EventHandler() {
+      @Override
+      public void handle(Event e) {
+        throw new IllegalStateException();
+      }
+    };
+
+    MemoizingEvaluator aug = new InMemoryMemoizingEvaluator(
+        ImmutableMap.of(GraphTester.NODE_TYPE, tester.getFunction()), new RecordingDifferencer(),
+        progressReceiver);
+    SequentialBuildDriver driver = new SequentialBuildDriver(aug);
+
+    tester.getOrCreate("top1").setComputedValue(CONCATENATE)
+        .addDependency("d1").addDependency("d2");
+    tester.getOrCreate("top2").setComputedValue(CONCATENATE).addDependency("d3");
+    tester.getOrCreate("top3");
+    assertThat(enqueuedValues).isEmpty();
+    assertThat(evaluatedValues).isEmpty();
+
+    tester.set("d1", new StringValue("1"));
+    tester.set("d2", new StringValue("2"));
+    tester.set("d3", new StringValue("3"));
+
+    driver.evaluate(ImmutableList.of(GraphTester.toSkyKey("top1")), false, 200, reporter);
+    assertThat(enqueuedValues)
+        .containsExactlyElementsIn(Arrays.asList(GraphTester.toSkyKeys("top1", "d1", "d2")));
+    assertThat(evaluatedValues)
+        .containsExactlyElementsIn(Arrays.asList(GraphTester.toSkyKeys("top1", "d1", "d2")));
+    enqueuedValues.clear();
+    evaluatedValues.clear();
+
+    driver.evaluate(ImmutableList.of(GraphTester.toSkyKey("top2")), false, 200, reporter);
+    assertThat(enqueuedValues)
+        .containsExactlyElementsIn(Arrays.asList(GraphTester.toSkyKeys("top2", "d3")));
+    assertThat(evaluatedValues)
+        .containsExactlyElementsIn(Arrays.asList(GraphTester.toSkyKeys("top2", "d3")));
+    enqueuedValues.clear();
+    evaluatedValues.clear();
+
+    driver.evaluate(ImmutableList.of(GraphTester.toSkyKey("top1")), false, 200, reporter);
+    assertThat(enqueuedValues).isEmpty();
+    assertThat(evaluatedValues)
+        .containsExactlyElementsIn(Arrays.asList(GraphTester.toSkyKeys("top1")));
+  }
+
+  public void runDepOnErrorHaltsNoKeepGoingBuildEagerly(boolean childErrorCached,
+      final boolean handleChildError) throws Exception {
+    graph = new InMemoryGraph();
+    SkyKey parentKey = GraphTester.toSkyKey("parent");
+    final SkyKey childKey = GraphTester.toSkyKey("child");
+    tester.getOrCreate(childKey).setHasError(/*hasError=*/true);
+    // The parent should be built exactly twice: once during normal evaluation and once
+    // during error bubbling.
+    final AtomicInteger numParentInvocations = new AtomicInteger(0);
+    tester.getOrCreate(parentKey).setBuilder(new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException {
+        int invocations = numParentInvocations.incrementAndGet();
+        if (handleChildError) {
+          try {
+            SkyValue value = env.getValueOrThrow(childKey, SomeErrorException.class);
+            // On the first invocation, either the child error should already be cached and not
+            // propagated, or it should be computed freshly and not propagated. On the second build
+            // (error bubbling), the child error should be propagated.
+            assertTrue("bogus non-null value " + value, value == null);
+            assertEquals("parent incorrectly re-computed during normal evaluation", 1, invocations);
+            assertFalse("child error not propagated during error bubbling",
+                env.inErrorBubblingForTesting());
+            return value;
+          } catch (SomeErrorException e) {
+            assertTrue("child error propagated during normal evaluation",
+                env.inErrorBubblingForTesting());
+            assertEquals(2, invocations);
+            return null;
+          }
+        } else {
+          if (invocations == 1) {
+            assertFalse("parent's first computation should be during normal evaluation",
+                env.inErrorBubblingForTesting());
+            return env.getValue(childKey);
+          } else {
+            assertEquals(2, invocations);
+            assertTrue("parent incorrectly re-computed during normal evaluation",
+                env.inErrorBubblingForTesting());
+            return env.getValue(childKey);
+          }
+        }
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        return null;
+      }
+    });
+    if (childErrorCached) {
+      // Ensure that the child is already in the graph.
+      evalValueInError(childKey);
+    }
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/false, ImmutableList.of(parentKey));
+    assertEquals(2, numParentInvocations.get());
+    assertTrue(result.hasError());
+    assertEquals(childKey, result.getError().getRootCauseOfException());
+  }
+
+  @Test
+  public void depOnErrorHaltsNoKeepGoingBuildEagerly_ChildErrorCachedAndHandled()
+      throws Exception {
+    runDepOnErrorHaltsNoKeepGoingBuildEagerly(/*childErrorCached=*/true,
+        /*handleChildError=*/true);
+  }
+
+  @Test
+  public void depOnErrorHaltsNoKeepGoingBuildEagerly_ChildErrorCachedAndNotHandled()
+      throws Exception {
+    runDepOnErrorHaltsNoKeepGoingBuildEagerly(/*childErrorCached=*/true,
+        /*handleChildError=*/false);
+  }
+
+  @Test
+  public void depOnErrorHaltsNoKeepGoingBuildEagerly_ChildErrorFreshAndHandled() throws Exception {
+    runDepOnErrorHaltsNoKeepGoingBuildEagerly(/*childErrorCached=*/false,
+        /*handleChildError=*/true);
+  }
+
+  @Test
+  public void depOnErrorHaltsNoKeepGoingBuildEagerly_ChildErrorFreshAndNotHandled()
+      throws Exception {
+    runDepOnErrorHaltsNoKeepGoingBuildEagerly(/*childErrorCached=*/false,
+        /*handleChildError=*/false);
+  }
+
+  @Test
+  public void raceConditionWithNoKeepGoingErrors_InflightError() throws Exception {
+    final CountDownLatch errorCommitted = new CountDownLatch(1);
+    final TrackingAwaiter trackingAwaiterForError = new TrackingAwaiter();
+    final CountDownLatch otherDone = new CountDownLatch(1);
+    final TrackingAwaiter trackingAwaiterForOther = new TrackingAwaiter();
+    final SkyKey errorKey = GraphTester.toSkyKey("errorKey");
+    final SkyKey otherKey = GraphTester.toSkyKey("otherKey");
+    tester.getOrCreate(errorKey).setHasError(true);
+    final AtomicInteger numOtherInvocations = new AtomicInteger(0);
+    tester.getOrCreate(otherKey).setBuilder(new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException {
+        int invocations = numOtherInvocations.incrementAndGet();
+        if (invocations == 1) {
+          trackingAwaiterForError.awaitLatchAndTrackExceptions(errorCommitted,
+              "error didn't get committed to the graph in time");
+        }
+        try {
+          SkyValue value = env.getValueOrThrow(errorKey, SomeErrorException.class);
+          assertTrue("bogus non-null value " + value, value == null);
+          assertEquals(1, invocations);
+          otherDone.countDown();
+          throw new GenericFunctionException(new SomeErrorException("other"),
+              Transience.PERSISTENT);
+        } catch (SomeErrorException e) {
+          assertEquals(2, invocations);
+          return null;
+        }
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        return null;
+      }
+    });
+    graph = new NotifyingInMemoryGraph(new Listener() {
+      @Override
+      public void accept(SkyKey key, EventType type, Order order, Object context) {
+        if (key.equals(errorKey) && type == EventType.SET_VALUE && order == Order.AFTER) {
+          errorCommitted.countDown();
+          trackingAwaiterForOther.awaitLatchAndTrackExceptions(otherDone,
+              "otherKey's SkyFunction didn't finish in time");
+        }
+      }
+    });
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/false,
+        ImmutableList.of(errorKey, otherKey));
+    assertEquals(null, graph.get(otherKey));
+    assertTrue(result.hasError());
+    assertEquals(errorKey, result.getError().getRootCauseOfException());
+  }
+
+  @Test
+  public void raceConditionWithNoKeepGoingErrors_FutureError() throws Exception {
+    final CountDownLatch errorCommitted = new CountDownLatch(1);
+    final TrackingAwaiter trackingAwaiterForError = new TrackingAwaiter();
+    final CountDownLatch otherStarted = new CountDownLatch(1);
+    final TrackingAwaiter trackingAwaiterForOther = new TrackingAwaiter();
+    final CountDownLatch otherParentSignaled = new CountDownLatch(1);
+    final TrackingAwaiter trackingAwaiterForOtherParent = new TrackingAwaiter();
+    final SkyKey errorParentKey = GraphTester.toSkyKey("errorParentKey");
+    final SkyKey errorKey = GraphTester.toSkyKey("errorKey");
+    final SkyKey otherParentKey = GraphTester.toSkyKey("otherParentKey");
+    final SkyKey otherKey = GraphTester.toSkyKey("otherKey");
+    final AtomicInteger numOtherParentInvocations = new AtomicInteger(0);
+    final AtomicInteger numErrorParentInvocations = new AtomicInteger(0);
+    tester.getOrCreate(otherParentKey).setBuilder(new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException {
+        int invocations = numOtherParentInvocations.incrementAndGet();
+        assertEquals("otherParentKey should not be restarted", 1, invocations);
+        return env.getValue(otherKey);
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        return null;
+      }
+    });
+    tester.getOrCreate(otherKey).setBuilder(new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException {
+        otherStarted.countDown();
+        trackingAwaiterForError.awaitLatchAndTrackExceptions(errorCommitted,
+            "error didn't get committed to the graph in time");
+        return new StringValue("other");
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        return null;
+      }
+    });
+    tester.getOrCreate(errorKey).setBuilder(new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException {
+        trackingAwaiterForOther.awaitLatchAndTrackExceptions(otherStarted,
+            "other didn't start in time");
+        throw new GenericFunctionException(new SomeErrorException("error"),
+            Transience.PERSISTENT);
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        return null;
+      }
+    });
+    tester.getOrCreate(errorParentKey).setBuilder(new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException {
+        int invocations = numErrorParentInvocations.incrementAndGet();
+        try {
+          SkyValue value = env.getValueOrThrow(errorKey, SomeErrorException.class);
+          assertTrue("bogus non-null value " + value, value == null);
+          if (invocations == 1) {
+            return null;
+          } else {
+            assertFalse(env.inErrorBubblingForTesting());
+            fail("RACE CONDITION: errorParentKey was restarted!");
+            return null;
+          }
+        } catch (SomeErrorException e) {
+          assertTrue("child error propagated during normal evaluation",
+              env.inErrorBubblingForTesting());
+          assertEquals(2, invocations);
+          return null;
+        }
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        return null;
+      }
+    });
+    graph = new NotifyingInMemoryGraph(new Listener() {
+      @Override
+      public void accept(SkyKey key, EventType type, Order order, Object context) {
+        if (key.equals(errorKey) && type == EventType.SET_VALUE && order == Order.AFTER) {
+          errorCommitted.countDown();
+          trackingAwaiterForOtherParent.awaitLatchAndTrackExceptions(otherParentSignaled,
+              "otherParent didn't get signaled in time");
+          // We try to give some time for ParallelEvaluator to incorrectly re-evaluate
+          // 'otherParentKey'. This test case is testing for a real race condition and the 10ms time
+          // was chosen experimentally to give a true positive rate of 99.8% (without a sleep it
+          // has a 1% true positive rate). There's no good way to do this without sleeping. We
+          // *could* introspect ParallelEvaulator's AbstractQueueVisitor to see if the re-evaluation
+          // has been enqueued, but that's relying on pretty low-level implementation details.
+          Uninterruptibles.sleepUninterruptibly(10, TimeUnit.MILLISECONDS);
+        }
+        if (key.equals(otherParentKey) && type == EventType.SIGNAL && order == Order.AFTER) {
+          otherParentSignaled.countDown();
+        }
+      }
+    });
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/false,
+        ImmutableList.of(otherParentKey, errorParentKey));
+    assertTrue(result.hasError());
+    assertEquals(errorKey, result.getError().getRootCauseOfException());
+  }
+
+  @Test
+  public void cachedErrorsFromKeepGoingUsedOnNoKeepGoing() throws Exception {
+    graph = new DeterministicInMemoryGraph();
+    tester = new GraphTester();
+    SkyKey errorKey = GraphTester.toSkyKey("error");
+    SkyKey parent1Key = GraphTester.toSkyKey("parent1");
+    SkyKey parent2Key = GraphTester.toSkyKey("parent2");
+    tester.getOrCreate(parent1Key).addDependency(errorKey).setConstantValue(
+        new StringValue("parent1"));
+    tester.getOrCreate(parent2Key).addDependency(errorKey).setConstantValue(
+        new StringValue("parent2"));
+    tester.getOrCreate(errorKey).setHasError(true);
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/true, ImmutableList.of(parent1Key));
+    assertTrue(result.hasError());
+    assertEquals(errorKey, result.getError().getRootCauseOfException());
+    result = eval(/*keepGoing=*/false, ImmutableList.of(parent2Key));
+    assertTrue(result.hasError());
+    assertEquals(errorKey, result.getError(parent2Key).getRootCauseOfException());
+  }
+
+  @Test
+  public void cachedTopLevelErrorsShouldHaltNoKeepGoingBuildEarly() throws Exception {
+    graph = new DeterministicInMemoryGraph();
+    tester = new GraphTester();
+    SkyKey errorKey = GraphTester.toSkyKey("error");
+    tester.getOrCreate(errorKey).setHasError(true);
+    EvaluationResult<StringValue> result = eval(/*keepGoing=*/true, ImmutableList.of(errorKey));
+    assertTrue(result.hasError());
+    assertEquals(errorKey, result.getError().getRootCauseOfException());
+    SkyKey rogueKey = GraphTester.toSkyKey("rogue");
+    tester.getOrCreate(rogueKey).setBuilder(new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) {
+        // This SkyFunction could do an arbitrarily bad computation, e.g. loop-forever. So we want
+        // to make sure that it is never run when we want to fail-fast anyway.
+        fail("eval call should have already terminated");
+        return null;
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        return null;
+      }
+    });
+    result = eval(/*keepGoing=*/false, ImmutableList.of(errorKey, rogueKey));
+    assertTrue(result.hasError());
+    assertEquals(errorKey, result.getError(errorKey).getRootCauseOfException());
+    assertFalse(result.errorMap().containsKey(rogueKey));
+  }
+
+  private void runUnhandledTransitiveErrors(boolean keepGoing,
+      final boolean explicitlyPropagateError) throws Exception {
+    graph = new DeterministicInMemoryGraph();
+    tester = new GraphTester();
+    SkyKey grandparentKey = GraphTester.toSkyKey("grandparent");
+    final SkyKey parentKey = GraphTester.toSkyKey("parent");
+    final SkyKey childKey = GraphTester.toSkyKey("child");
+    final AtomicBoolean errorPropagated = new AtomicBoolean(false);
+    tester.getOrCreate(grandparentKey).setBuilder(new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException {
+        try {
+          return env.getValueOrThrow(parentKey, SomeErrorException.class);
+        } catch (SomeErrorException e) {
+          errorPropagated.set(true);
+          throw new GenericFunctionException(e, Transience.PERSISTENT);
+        }
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        return null;
+      }
+    });
+    tester.getOrCreate(parentKey).setBuilder(new SkyFunction() {
+      @Override
+      public SkyValue compute(SkyKey skyKey, Environment env) throws SkyFunctionException {
+        if (explicitlyPropagateError) {
+          try {
+            return env.getValueOrThrow(childKey, SomeErrorException.class);
+          } catch (SomeErrorException e) {
+            throw new GenericFunctionException(e, childKey);
+          }
+        } else {
+          return env.getValue(childKey);
+        }
+      }
+
+      @Override
+      public String extractTag(SkyKey skyKey) {
+        return null;
+      }
+    });
+    tester.getOrCreate(childKey).setHasError(/*hasError=*/true);
+    EvaluationResult<StringValue> result = eval(keepGoing, ImmutableList.of(grandparentKey));
+    assertTrue(result.hasError());
+    assertTrue(errorPropagated.get());
+    assertEquals(grandparentKey, result.getError().getRootCauseOfException());
+  }
+
+  @Test
+  public void unhandledTransitiveErrorsDuringErrorBubbling_ImplicitPropagation() throws Exception {
+    runUnhandledTransitiveErrors(/*keepGoing=*/false, /*explicitlyPropagateError=*/false);
+  }
+
+  @Test
+  public void unhandledTransitiveErrorsDuringErrorBubbling_ExplicitPropagation() throws Exception {
+    runUnhandledTransitiveErrors(/*keepGoing=*/false, /*explicitlyPropagateError=*/true);
+  }
+
+  @Test
+  public void unhandledTransitiveErrorsDuringNormalEvaluation_ImplicitPropagation()
+      throws Exception {
+    runUnhandledTransitiveErrors(/*keepGoing=*/true, /*explicitlyPropagateError=*/false);
+  }
+
+  @Test
+  public void unhandledTransitiveErrorsDuringNormalEvaluation_ExplicitPropagation()
+      throws Exception {
+    runUnhandledTransitiveErrors(/*keepGoing=*/true, /*explicitlyPropagateError=*/true);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/skyframe/ReverseDepsUtilTest.java b/src/test/java/com/google/devtools/build/skyframe/ReverseDepsUtilTest.java
new file mode 100644
index 0000000..9183775
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skyframe/ReverseDepsUtilTest.java
@@ -0,0 +1,155 @@
+// Copyright 2014 Google Inc. 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.skyframe;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Test for {@code ReverseDepsUtil}.
+ */
+@RunWith(Parameterized.class)
+public class ReverseDepsUtilTest {
+
+  private static final SkyFunctionName NODE_TYPE = new SkyFunctionName("Type", false);
+  private final int numElements;
+
+  @Parameters
+  public static List<Object[]> paramenters() {
+    List<Object[]> params = new ArrayList<>();
+    for (int i = 0; i < 20; i++) {
+      params.add(new Object[]{i});
+    }
+    return params;
+  }
+
+  public ReverseDepsUtilTest(int numElements) {
+    this.numElements = numElements;
+  }
+
+  private static final ReverseDepsUtil<Example> REVERSE_DEPS_UTIL = new ReverseDepsUtil<Example>() {
+    @Override
+    void setReverseDepsObject(Example container, Object object) {
+      container.reverseDeps = object;
+    }
+
+    @Override
+    void setSingleReverseDep(Example container, boolean singleObject) {
+      container.single = singleObject;
+    }
+
+    @Override
+    void setReverseDepsToRemove(Example container, List<SkyKey> object) {
+      container.reverseDepsToRemove = object;
+    }
+
+    @Override
+    Object getReverseDepsObject(Example container) {
+      return container.reverseDeps;
+    }
+
+    @Override
+    boolean isSingleReverseDep(Example container) {
+      return container.single;
+    }
+
+    @Override
+    List<SkyKey> getReverseDepsToRemove(Example container) {
+      return container.reverseDepsToRemove;
+    }
+  };
+
+  private class Example {
+
+    Object reverseDeps = ImmutableList.of();
+    boolean single;
+    List<SkyKey> reverseDepsToRemove;
+  }
+
+  @Test
+  public void testAddAndRemove() {
+    for (int numRemovals = 0; numRemovals <= numElements; numRemovals++) {
+      Example example = new Example();
+      for (int j = 0; j < numElements; j++) {
+        REVERSE_DEPS_UTIL.addReverseDeps(example, Collections.singleton(new SkyKey(NODE_TYPE, j)));
+      }
+      // Not a big test but at least check that it does not blow up.
+      assertThat(REVERSE_DEPS_UTIL.toString(example)).isNotEmpty();
+      assertThat(REVERSE_DEPS_UTIL.getReverseDeps(example)).hasSize(numElements);
+      for (int i = 0; i < numRemovals; i++) {
+        REVERSE_DEPS_UTIL.removeReverseDep(example, new SkyKey(NODE_TYPE, i));
+      }
+      assertThat(REVERSE_DEPS_UTIL.getReverseDeps(example)).hasSize(numElements - numRemovals);
+      assertThat(example.reverseDepsToRemove).isNull();
+    }
+  }
+
+  // Same as testAdditionAndRemoval but we add all the reverse deps in one call.
+  @Test
+  public void testAddAllAndRemove() {
+    for (int numRemovals = 0; numRemovals <= numElements; numRemovals++) {
+      Example example = new Example();
+      List<SkyKey> toAdd = new ArrayList<>();
+      for (int j = 0; j < numElements; j++) {
+        toAdd.add(new SkyKey(NODE_TYPE, j));
+      }
+      REVERSE_DEPS_UTIL.addReverseDeps(example, toAdd);
+      assertThat(REVERSE_DEPS_UTIL.getReverseDeps(example)).hasSize(numElements);
+      for (int i = 0; i < numRemovals; i++) {
+        REVERSE_DEPS_UTIL.removeReverseDep(example, new SkyKey(NODE_TYPE, i));
+      }
+      assertThat(REVERSE_DEPS_UTIL.getReverseDeps(example)).hasSize(numElements - numRemovals);
+      assertThat(example.reverseDepsToRemove).isNull();
+    }
+  }
+
+  @Test
+  public void testDuplicateCheckOnGetReverseDeps() {
+    Example example = new Example();
+    for (int i = 0; i < numElements; i++) {
+      REVERSE_DEPS_UTIL.addReverseDeps(example, Collections.singleton(new SkyKey(NODE_TYPE, i)));
+    }
+    // Should only fail when we call getReverseDeps().
+    REVERSE_DEPS_UTIL.addReverseDeps(example, Collections.singleton(new SkyKey(NODE_TYPE, 0)));
+    try {
+      REVERSE_DEPS_UTIL.getReverseDeps(example);
+      assertThat(numElements).is(0);
+    } catch (Exception expected) { }
+  }
+
+  @Test
+  public void testMaybeCheck() {
+    Example example = new Example();
+    for (int i = 0; i < numElements; i++) {
+      REVERSE_DEPS_UTIL.addReverseDeps(example, Collections.singleton(new SkyKey(NODE_TYPE, i)));
+      // This should always succeed, since the next element is still not present.
+      REVERSE_DEPS_UTIL.maybeCheckReverseDepNotPresent(example, new SkyKey(NODE_TYPE, i + 1));
+    }
+    try {
+      REVERSE_DEPS_UTIL.maybeCheckReverseDepNotPresent(example, new SkyKey(NODE_TYPE, 0));
+      // Should only fail if empty or above the checking threshold.
+      assertThat(numElements == 0 || numElements >= ReverseDepsUtil.MAYBE_CHECK_THRESHOLD).isTrue();
+    } catch (Exception expected) { }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/skyframe/SomeErrorException.java b/src/test/java/com/google/devtools/build/skyframe/SomeErrorException.java
new file mode 100644
index 0000000..b25cbd3
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skyframe/SomeErrorException.java
@@ -0,0 +1,20 @@
+// Copyright 2014 Google Inc. 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.skyframe;
+
+public class SomeErrorException extends Exception {
+  public SomeErrorException(String msg) {
+    super(msg);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/skyframe/TrackingAwaiter.java b/src/test/java/com/google/devtools/build/skyframe/TrackingAwaiter.java
new file mode 100644
index 0000000..3757583
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skyframe/TrackingAwaiter.java
@@ -0,0 +1,78 @@
+// Copyright 2014 Google Inc. 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.skyframe;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Throwables;
+import com.google.common.util.concurrent.Uninterruptibles;
+import com.google.devtools.build.lib.testutil.TestUtils;
+import com.google.devtools.build.lib.util.Pair;
+
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/** Safely await {@link CountDownLatch}es in tests, storing any exceptions that happen. */
+public class TrackingAwaiter {
+  private final ConcurrentLinkedQueue<Pair<String, Throwable>> exceptionsThrown =
+      new ConcurrentLinkedQueue<>();
+
+  /**
+   * This method fixes a race condition with simply calling {@link CountDownLatch#await}. If this
+   * thread is interrupted before {@code latch.await} is called, then {@code latch.await} will throw
+   * an {@link InterruptedException} without checking the value of the latch at all. This leads to a
+   * race condition in which this thread will throw an InterruptedException if it is slow calling
+   * {@code latch.await}, but it will succeed normally otherwise.
+   *
+   * <p>To avoid this, we wait for the latch uninterruptibly. In the end, if the latch has in fact
+   * been released, we do nothing, although the interrupted bit is set, so that the caller can
+   * decide to throw an InterruptedException if it wants to. If the latch was not released, then
+   * this was not a race condition, but an honest-to-goodness interrupt, and we propagate the
+   * exception onward.
+   */
+  public static void waitAndMaybeThrowInterrupt(CountDownLatch latch, String errorMessage)
+      throws InterruptedException {
+    if (Uninterruptibles.awaitUninterruptibly(latch, TestUtils.WAIT_TIMEOUT_SECONDS,
+        TimeUnit.SECONDS)) {
+      // Latch was released. We can ignore the interrupt state.
+      return;
+    }
+    if (!Thread.currentThread().isInterrupted()) {
+      // Nobody interrupted us, but latch wasn't released. Failure.
+      throw new AssertionError(errorMessage);
+    } else {
+      // We were interrupted before the latch was released. Propagate this interruption.
+      throw new InterruptedException();
+    }
+  }
+
+  /** Threadpools can swallow exceptions. Make sure they don't get lost. */
+  public void awaitLatchAndTrackExceptions(CountDownLatch latch, String errorMessage) {
+    try {
+      waitAndMaybeThrowInterrupt(latch, errorMessage);
+    } catch (Throwable e) {
+      // We would expect e to be InterruptedException or AssertionError, but we leave it open so
+      // that any throwable gets recorded.
+      exceptionsThrown.add(Pair.of(errorMessage, e));
+      // Caller will assert exceptionsThrown is empty at end of test and fail, even if this is
+      // swallowed.
+      Throwables.propagate(e);
+    }
+  }
+
+  public void assertNoErrors() {
+    assertThat(exceptionsThrown).isEmpty();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/skyframe/TrackingInvalidationReceiver.java b/src/test/java/com/google/devtools/build/skyframe/TrackingInvalidationReceiver.java
new file mode 100644
index 0000000..e93098d
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/skyframe/TrackingInvalidationReceiver.java
@@ -0,0 +1,68 @@
+// Copyright 2014 Google Inc. 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.skyframe;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.Sets;
+
+import java.util.Set;
+
+/**
+ * A testing utility to keep track of evaluation.
+ */
+public class TrackingInvalidationReceiver implements EvaluationProgressReceiver {
+  public final Set<SkyValue> dirty = Sets.newConcurrentHashSet();
+  public final Set<SkyValue> deleted = Sets.newConcurrentHashSet();
+  public final Set<SkyKey> enqueued = Sets.newConcurrentHashSet();
+  public final Set<SkyKey> evaluated = Sets.newConcurrentHashSet();
+
+  @Override
+  public void invalidated(SkyValue value, InvalidationState state) {
+    switch (state) {
+      case DELETED:
+        dirty.remove(value);
+        deleted.add(value);
+        break;
+      case DIRTY:
+        dirty.add(value);
+        Preconditions.checkState(!deleted.contains(value));
+        break;
+      default:
+        throw new IllegalStateException();
+    }
+  }
+
+  @Override
+  public void enqueueing(SkyKey skyKey) {
+    enqueued.add(skyKey);
+  }
+
+  @Override
+  public void evaluated(SkyKey skyKey, SkyValue value, EvaluationState state) {
+    evaluated.add(skyKey);
+    switch (state) {
+      default:
+        dirty.remove(value);
+        deleted.remove(value);
+        break;
+    }
+  }
+
+  public void clear() {
+    dirty.clear();
+    deleted.clear();
+    enqueued.clear();
+    evaluated.clear();
+  }
+}
diff --git a/src/test/java/com/google/devtools/common/options/AllTests.java b/src/test/java/com/google/devtools/common/options/AllTests.java
new file mode 100644
index 0000000..14d6abb
--- /dev/null
+++ b/src/test/java/com/google/devtools/common/options/AllTests.java
@@ -0,0 +1,25 @@
+// Copyright 2014 Google Inc. 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.common.options;
+
+import com.google.devtools.build.lib.testutil.ClasspathSuite;
+
+import org.junit.runner.RunWith;
+
+/**
+ * Test suite for options parsing framework.
+ */
+@RunWith(ClasspathSuite.class)
+public class AllTests {
+}
diff --git a/src/test/java/com/google/devtools/common/options/AssignmentConverterTest.java b/src/test/java/com/google/devtools/common/options/AssignmentConverterTest.java
new file mode 100644
index 0000000..ecaef4c
--- /dev/null
+++ b/src/test/java/com/google/devtools/common/options/AssignmentConverterTest.java
@@ -0,0 +1,108 @@
+// Copyright 2014 Google Inc. 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.common.options;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.Maps;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Map;
+
+/**
+ * Test for {@link Converters.AssignmentConverter} and
+ * {@link Converters.OptionalAssignmentConverter}.
+ */
+public abstract class AssignmentConverterTest {
+
+  protected Converter<Map.Entry<String, String>> converter = null;
+
+  protected abstract void setConverter();
+
+  protected Map.Entry<String, String> convert(String input) throws Exception {
+    return converter.convert(input);
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    setConverter();
+  }
+
+  @Test
+  public void assignment() throws Exception {
+    assertEquals(Maps.immutableEntry("A", "1"), convert("A=1"));
+    assertEquals(Maps.immutableEntry("A", "ABC"), convert("A=ABC"));
+    assertEquals(Maps.immutableEntry("A", ""), convert("A="));
+  }
+
+  @Test
+  public void missingName() throws Exception {
+    try {
+      convert("=VALUE");
+      fail();
+    } catch (OptionsParsingException e) {
+      // expected.
+    }
+  }
+
+  @Test
+  public void emptyString() throws Exception {
+    try {
+      convert("");
+      fail();
+    } catch (OptionsParsingException e) {
+      // expected.
+    }
+  }
+
+
+  @RunWith(JUnit4.class)
+  public static class MandatoryAssignmentConverterTest extends AssignmentConverterTest {
+
+    @Override
+    protected void setConverter() {
+      converter = new Converters.AssignmentConverter();
+    }
+
+    @Test
+    public void missingValue() throws Exception {
+      try {
+        convert("NAME");
+        fail();
+      } catch (OptionsParsingException e) {
+        // expected.
+      }
+    }
+  }
+
+  @RunWith(JUnit4.class)
+  public static class OptionalAssignmentConverterTest extends AssignmentConverterTest {
+
+    @Override
+    protected void setConverter() {
+      converter = new Converters.OptionalAssignmentConverter();
+    }
+
+    @Test
+    public void missingValue() throws Exception {
+      assertEquals(Maps.immutableEntry("NAME", null), convert("NAME"));
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/common/options/CommaSeparatedOptionListConverterTest.java b/src/test/java/com/google/devtools/common/options/CommaSeparatedOptionListConverterTest.java
new file mode 100644
index 0000000..7308a91
--- /dev/null
+++ b/src/test/java/com/google/devtools/common/options/CommaSeparatedOptionListConverterTest.java
@@ -0,0 +1,83 @@
+// Copyright 2014 Google Inc. 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.common.options;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A test for {@link Converters.CommaSeparatedOptionListConverter}.
+ */
+@RunWith(JUnit4.class)
+public class CommaSeparatedOptionListConverterTest {
+
+  private Converter<List<String>> converter =
+      new Converters.CommaSeparatedOptionListConverter();
+
+  @Test
+  public void emptyStringYieldsEmptyList() throws Exception {
+    assertEquals(Collections.emptyList(), converter.convert(""));
+  }
+
+  @Test
+  public void commaTwoEmptyStrings() throws Exception {
+    assertEquals(Arrays.asList("", ""), converter.convert(","));
+  }
+
+  @Test
+  public void leadingCommaYieldsLeadingSpace() throws Exception {
+    assertEquals(Arrays.asList("", "leading", "comma"),
+                 converter.convert(",leading,comma"));
+  }
+
+  @Test
+  public void trailingCommaYieldsTrailingSpace() throws Exception {
+    assertEquals(Arrays.asList("trailing", "comma", ""),
+                 converter.convert("trailing,comma,"));
+  }
+
+  @Test
+  public void singleWord() throws Exception {
+    assertEquals(Arrays.asList("lonely"), converter.convert("lonely"));
+  }
+
+  @Test
+  public void multiWords() throws Exception {
+    assertEquals(Arrays.asList("one", "two", "three"),
+                 converter.convert("one,two,three"));
+  }
+
+  @Test
+  public void spaceIsIgnored() throws Exception {
+    assertEquals(Arrays.asList("one two three"),
+                 converter.convert("one two three"));
+  }
+
+  @Test
+  public void valueisUnmodifiable() throws Exception {
+    try {
+      converter.convert("value").add("other");
+      fail("could modify value");
+    } catch (UnsupportedOperationException expected) {}
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/common/options/EnumConverterTest.java b/src/test/java/com/google/devtools/common/options/EnumConverterTest.java
new file mode 100644
index 0000000..5154695
--- /dev/null
+++ b/src/test/java/com/google/devtools/common/options/EnumConverterTest.java
@@ -0,0 +1,117 @@
+// Copyright 2014 Google Inc. 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.common.options;
+
+import static com.google.devtools.common.options.OptionsParser.newOptionsParser;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.fail;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.List;
+
+/**
+ * A test for {@link EnumConverter}.
+ */
+@RunWith(JUnit4.class)
+public class EnumConverterTest {
+
+  private enum CompilationMode {
+    DBG, OPT
+  }
+
+  private static class CompilationModeConverter
+    extends EnumConverter<CompilationMode> {
+
+    public CompilationModeConverter() {
+      super(CompilationMode.class, "compilation mode");
+    }
+  }
+
+  @Test
+  public void converterForEnumWithTwoValues() throws Exception {
+    CompilationModeConverter converter = new CompilationModeConverter();
+    assertEquals(converter.convert("dbg"), CompilationMode.DBG);
+    assertEquals(converter.convert("opt"), CompilationMode.OPT);
+    try {
+      converter.convert("none");
+      fail();
+    } catch(OptionsParsingException e) {
+      assertEquals(e.getMessage(),
+                   "Not a valid compilation mode: 'none' (should be dbg or opt)");
+    }
+    assertEquals("dbg or opt", converter.getTypeDescription());
+  }
+
+  private enum Fruit {
+    Apple, Banana, Cherry
+  }
+
+  private static class FruitConverter extends EnumConverter<Fruit> {
+
+    public FruitConverter() {
+      super(Fruit.class, "fruit");
+    }
+  }
+
+  @Test
+  public void typeDescriptionForEnumWithThreeValues() throws Exception {
+    FruitConverter converter = new FruitConverter();
+    // We always use lowercase in the user-visible messages:
+    assertEquals("apple, banana or cherry",
+                 converter.getTypeDescription());
+  }
+
+  @Test
+  public void converterIsCaseInsensitive() throws Exception {
+    FruitConverter converter = new FruitConverter();
+    assertSame(Fruit.Banana, converter.convert("bAnANa"));
+  }
+
+  // Regression test: lists of enum using a subclass of EnumConverter don't work
+  private static class AlphabetEnumConverter extends EnumConverter<AlphabetEnum> {
+    public AlphabetEnumConverter() {
+      super(AlphabetEnum.class, "alphabet enum");
+    }
+  }
+
+  private static enum AlphabetEnum {
+    ALPHA, BRAVO, CHARLY, DELTA, ECHO
+  }
+
+  public static class EnumListTestOptions extends OptionsBase {
+    @Option(name = "goo",
+            allowMultiple = true,
+            converter = AlphabetEnumConverter.class,
+            defaultValue = "null")
+    public List<AlphabetEnum> goo;
+  }
+
+  @Test
+  public void enumList() throws OptionsParsingException {
+    OptionsParser parser = newOptionsParser(EnumListTestOptions.class);
+    parser.parse("--goo=ALPHA", "--goo=BRAVO");
+    EnumListTestOptions options = parser.getOptions(EnumListTestOptions.class);
+    assertNotNull(options.goo);
+    assertEquals(2, options.goo.size());
+    assertEquals(AlphabetEnum.ALPHA, options.goo.get(0));
+    assertEquals(AlphabetEnum.BRAVO, options.goo.get(1));
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/common/options/GenericTypeHelperTest.java b/src/test/java/com/google/devtools/common/options/GenericTypeHelperTest.java
new file mode 100644
index 0000000..e3a02ac
--- /dev/null
+++ b/src/test/java/com/google/devtools/common/options/GenericTypeHelperTest.java
@@ -0,0 +1,74 @@
+// Copyright 2014 Google Inc. 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.common.options;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests {@link GenericTypeHelper}.
+ */
+@RunWith(JUnit4.class)
+public class GenericTypeHelperTest {
+
+  private static interface DoSomething<T> {
+    T doIt();
+  }
+
+  private static class StringSomething implements DoSomething<String> {
+    @Override
+    public String doIt() {
+      return null;
+    }
+  }
+
+  private static class EnumSomething<T> implements DoSomething<T> {
+    @Override
+    public T doIt() {
+      return null;
+    }
+  }
+
+  private static class AlphabetSomething extends EnumSomething<String> {
+  }
+
+  private static class AlphabetTwoSomething extends AlphabetSomething {
+  }
+
+  private static void assertDoIt(Class<?> expected,
+      Class<? extends DoSomething<?>> implementingClass) throws Exception {
+    assertEquals(expected,
+        GenericTypeHelper.getActualReturnType(implementingClass,
+            implementingClass.getMethod("doIt")));
+  }
+
+  @Test
+  public void getConverterType() throws Exception {
+    assertDoIt(String.class, StringSomething.class);
+  }
+
+  @Test
+  public void getConverterTypeForGenericExtension() throws Exception {
+    assertDoIt(String.class, AlphabetSomething.class);
+  }
+
+  @Test
+  public void getConverterTypeForGenericExtensionSecondGrade() throws Exception {
+    assertDoIt(String.class, AlphabetTwoSomething.class);
+  }
+}
diff --git a/src/test/java/com/google/devtools/common/options/LogLevelConverterTest.java b/src/test/java/com/google/devtools/common/options/LogLevelConverterTest.java
new file mode 100644
index 0000000..4dfa209
--- /dev/null
+++ b/src/test/java/com/google/devtools/common/options/LogLevelConverterTest.java
@@ -0,0 +1,66 @@
+// Copyright 2014 Google Inc. 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.common.options;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import com.google.devtools.common.options.Converters.LogLevelConverter;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.logging.Level;
+
+/**
+ * A test for {@link LogLevelConverter}.
+ */
+@RunWith(JUnit4.class)
+public class LogLevelConverterTest {
+
+  private LogLevelConverter converter = new LogLevelConverter();
+
+  @Test
+  public void convertsIntsToLevels() throws OptionsParsingException {
+    int levelId = 0;
+    for (Level level : LogLevelConverter.LEVELS) {
+      assertEquals(level, converter.convert(Integer.toString(levelId++)));
+    }
+  }
+
+  @Test
+  public void throwsExceptionWhenInputIsNotANumber() {
+    try {
+      converter.convert("oops - not a number.");
+      fail();
+    } catch (OptionsParsingException e) {
+      assertEquals("Not a log level: oops - not a number.", e.getMessage());
+    }
+  }
+
+  @Test
+  public void throwsExceptionWhenInputIsInvalidInteger() {
+    for (int example : new int[] {-1, 100, 50000}) {
+      try {
+        converter.convert(Integer.toString(example));
+        fail();
+      } catch (OptionsParsingException e) {
+        String expected = "Not a log level: " + Integer.toString(example);
+        assertEquals(expected, e.getMessage());
+      }
+    }
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/common/options/OptionsParserTest.java b/src/test/java/com/google/devtools/common/options/OptionsParserTest.java
new file mode 100644
index 0000000..190d855
--- /dev/null
+++ b/src/test/java/com/google/devtools/common/options/OptionsParserTest.java
@@ -0,0 +1,1026 @@
+// Copyright 2014 Google Inc. 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.common.options;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.common.options.OptionsParser.newOptionsParser;
+import static java.util.Arrays.asList;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.devtools.common.options.Converters.CommaSeparatedOptionListConverter;
+import com.google.devtools.common.options.OptionsParser.OptionValueDescription;
+import com.google.devtools.common.options.OptionsParser.UnparsedOptionValueDescription;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Tests {@link OptionsParser}.
+ */
+@RunWith(JUnit4.class)
+public class OptionsParserTest {
+
+  public static class ExampleFoo extends OptionsBase {
+
+    @Option(name = "foo",
+            category = "one",
+            defaultValue = "defaultFoo")
+    public String foo;
+
+    @Option(name = "bar",
+            category = "two",
+            defaultValue = "42")
+    public int bar;
+
+    @Option(name = "bing",
+            category = "one",
+            defaultValue = "",
+            allowMultiple = true)
+    public List<String> bing;
+
+    @Option(name = "bang",
+            category = "one",
+            defaultValue = "",
+            converter = StringConverter.class,
+            allowMultiple = true)
+    public List<String> bang;
+
+    @Option(name = "nodoc",
+        category = "undocumented",
+        defaultValue = "",
+        allowMultiple = false)
+    public String nodoc;
+  }
+
+  public static class ExampleBaz extends OptionsBase {
+
+    @Option(name = "baz",
+            category = "one",
+            defaultValue = "defaultBaz")
+    public String baz;
+  }
+
+  public static class StringConverter implements Converter<String> {
+    @Override
+    public String convert(String input) {
+      return input;
+    }
+    @Override
+    public String getTypeDescription() {
+      return "a string";
+    }
+  }
+
+  @Test
+  public void parseWithMultipleOptionsInterfaces()
+      throws OptionsParsingException {
+    OptionsParser parser = newOptionsParser(ExampleFoo.class, ExampleBaz.class);
+    parser.parse("--baz=oops", "--bar", "17");
+    ExampleFoo foo = parser.getOptions(ExampleFoo.class);
+    assertEquals("defaultFoo", foo.foo);
+    assertEquals(17, foo.bar);
+    ExampleBaz baz = parser.getOptions(ExampleBaz.class);
+    assertEquals("oops", baz.baz);
+  }
+
+  @Test
+  public void parserWithUnknownOption() {
+    OptionsParser parser = newOptionsParser(ExampleFoo.class, ExampleBaz.class);
+    try {
+      parser.parse("--unknown", "option");
+      fail();
+    } catch (OptionsParsingException e) {
+      assertEquals("--unknown", e.getInvalidArgument());
+      assertEquals("Unrecognized option: --unknown", e.getMessage());
+    }
+    assertEquals(Collections.<String>emptyList(), parser.getResidue());
+  }
+
+  @Test
+  public void parserWithSingleDashOption() throws OptionsParsingException {
+    OptionsParser parser = newOptionsParser(ExampleFoo.class, ExampleBaz.class);
+    try {
+      parser.parse("-baz=oops", "-bar", "17");
+      fail();
+    } catch (OptionsParsingException expected) {}
+
+    parser = newOptionsParser(ExampleFoo.class, ExampleBaz.class);
+    parser.setAllowSingleDashLongOptions(true);
+    parser.parse("-baz=oops", "-bar", "17");
+    ExampleFoo foo = parser.getOptions(ExampleFoo.class);
+    assertEquals("defaultFoo", foo.foo);
+    assertEquals(17, foo.bar);
+    ExampleBaz baz = parser.getOptions(ExampleBaz.class);
+    assertEquals("oops", baz.baz);
+  }
+
+  @Test
+  public void parsingFailsWithUnknownOptions() {
+    OptionsParser parser = newOptionsParser(ExampleFoo.class, ExampleBaz.class);
+    List<String> unknownOpts = asList("--unknown", "option", "--more_unknowns");
+    try {
+      parser.parse(unknownOpts);
+      fail();
+    } catch (OptionsParsingException e) {
+      assertEquals("--unknown", e.getInvalidArgument());
+      assertEquals("Unrecognized option: --unknown", e.getMessage());
+      assertNotNull(parser.getOptions(ExampleFoo.class));
+      assertNotNull(parser.getOptions(ExampleBaz.class));
+    }
+  }
+
+  @Test
+  public void parseKnownAndUnknownOptions() {
+    OptionsParser parser = newOptionsParser(ExampleFoo.class, ExampleBaz.class);
+    List<String> opts = asList("--bar", "17", "--unknown", "option");
+    try {
+      parser.parse(opts);
+      fail();
+    } catch (OptionsParsingException e) {
+      assertEquals("--unknown", e.getInvalidArgument());
+      assertEquals("Unrecognized option: --unknown", e.getMessage());
+      assertNotNull(parser.getOptions(ExampleFoo.class));
+      assertNotNull(parser.getOptions(ExampleBaz.class));
+    }
+  }
+
+  public static class CategoryTest extends OptionsBase {
+    @Option(name = "swiss_bank_account_number",
+            category = "undocumented", // Not printed in usage messages!
+            defaultValue = "123456789")
+    public int swissBankAccountNumber;
+
+    @Option(name = "student_bank_account_number",
+            category = "one",
+            defaultValue = "987654321")
+    public int studentBankAccountNumber;
+  }
+
+  @Test
+  public void getOptionsAndGetResidueWithNoCallToParse() {
+    // With no call to parse(), all options are at default values, and there's
+    // no reside.
+    assertEquals("defaultFoo",
+                 newOptionsParser(ExampleFoo.class).
+                 getOptions(ExampleFoo.class).foo);
+    assertEquals(Collections.<String>emptyList(),
+                 newOptionsParser(ExampleFoo.class).getResidue());
+  }
+
+  @Test
+  public void parserCanBeCalledRepeatedly() throws OptionsParsingException {
+    OptionsParser parser = newOptionsParser(ExampleFoo.class);
+    parser.parse("--foo", "foo1");
+    assertEquals("foo1", parser.getOptions(ExampleFoo.class).foo);
+    parser.parse();
+    assertEquals("foo1", parser.getOptions(ExampleFoo.class).foo); // no change
+    parser.parse("--foo", "foo2");
+    assertEquals("foo2", parser.getOptions(ExampleFoo.class).foo); // updated
+  }
+
+  @Test
+  public void multipleOccuringOption() throws OptionsParsingException {
+    OptionsParser parser = newOptionsParser(ExampleFoo.class);
+    parser.parse("--bing", "abcdef", "--foo", "foo1", "--bing", "123456" );
+    assertThat(parser.getOptions(ExampleFoo.class).bing).containsExactly("abcdef", "123456");
+  }
+
+  @Test
+  public void multipleOccurringOptionWithConverter() throws OptionsParsingException {
+    // --bang is the same as --bing except that it has a "converter" specified.
+    // This test also tests option values with embedded commas and spaces.
+    OptionsParser parser = newOptionsParser(ExampleFoo.class);
+    parser.parse("--bang", "abc,def ghi", "--foo", "foo1", "--bang", "123456" );
+    assertThat(parser.getOptions(ExampleFoo.class).bang).containsExactly("abc,def ghi", "123456");
+  }
+
+  @Test
+  public void parserIgnoresOptionsAfterMinusMinus()
+      throws OptionsParsingException {
+    OptionsParser parser = newOptionsParser(ExampleFoo.class, ExampleBaz.class);
+    parser.parse("--foo", "well", "--baz", "here", "--", "--bar", "ignore");
+    ExampleFoo foo = parser.getOptions(ExampleFoo.class);
+    ExampleBaz baz = parser.getOptions(ExampleBaz.class);
+    assertEquals("well", foo.foo);
+    assertEquals("here", baz.baz);
+    assertEquals(42, foo.bar); // the default!
+    assertEquals(asList("--bar", "ignore"), parser.getResidue());
+  }
+
+  @Test
+  public void parserThrowsExceptionIfResidueIsNotAllowed() {
+    OptionsParser parser = newOptionsParser(ExampleFoo.class);
+    parser.setAllowResidue(false);
+    try {
+      parser.parse("residue", "is", "not", "OK");
+      fail();
+    } catch (OptionsParsingException e) {
+      assertEquals("Unrecognized arguments: residue is not OK", e.getMessage());
+    }
+  }
+
+  @Test
+  public void multipleCallsToParse() throws Exception {
+    OptionsParser parser = newOptionsParser(ExampleFoo.class);
+    parser.setAllowResidue(true);
+    parser.parse("--foo", "one", "--bar", "43", "unknown1");
+    parser.parse("--foo", "two", "unknown2");
+    ExampleFoo foo = parser.getOptions(ExampleFoo.class);
+    assertEquals("two", foo.foo); // second call takes precedence
+    assertEquals(43, foo.bar);
+    assertEquals(Arrays.asList("unknown1", "unknown2"), parser.getResidue());
+  }
+
+  // Regression test for a subtle bug!  The toString of each options interface
+  // instance was printing out key=value pairs for all flags in the
+  // OptionsParser, not just those belonging to the specific interface type.
+  @Test
+  public void toStringDoesntIncludeFlagsForOtherOptionsInParserInstance()
+      throws Exception {
+    OptionsParser parser = newOptionsParser(ExampleFoo.class, ExampleBaz.class);
+    parser.parse("--foo", "foo", "--bar", "43", "--baz", "baz");
+
+    String fooString = parser.getOptions(ExampleFoo.class).toString();
+    if (!fooString.contains("foo=foo") ||
+        !fooString.contains("bar=43") ||
+        !fooString.contains("ExampleFoo") ||
+        fooString.contains("baz=baz")) {
+      fail("ExampleFoo.toString() is incorrect: " + fooString);
+    }
+
+    String bazString = parser.getOptions(ExampleBaz.class).toString();
+    if (!bazString.contains("baz=baz") ||
+        !bazString.contains("ExampleBaz") ||
+        bazString.contains("foo=foo") ||
+        bazString.contains("bar=43")) {
+      fail("ExampleBaz.toString() is incorrect: " + bazString);
+    }
+  }
+
+  // Regression test for another subtle bug!  The toString was printing all the
+  // explicitly-specified options, even if they were at their default values,
+  // causing toString equivalence to diverge from equals().
+  @Test
+  public void toStringIsIndependentOfExplicitCommandLineOptions() throws Exception {
+    ExampleFoo foo1 = Options.parse(ExampleFoo.class).getOptions();
+    ExampleFoo foo2 = Options.parse(ExampleFoo.class, "--bar", "42").getOptions();
+    assertEquals(foo1, foo2);
+    assertEquals(foo1.toString(), foo2.toString());
+
+    Map<String, Object> expectedMap = new ImmutableMap.Builder<String, Object>().
+        put("bing", Collections.emptyList()).
+        put("bar", 42).
+        put("nodoc", "").
+        put("bang", Collections.emptyList()).
+        put("foo", "defaultFoo").build();
+
+    assertEquals(expectedMap, foo1.asMap());
+    assertEquals(expectedMap, foo2.asMap());
+  }
+
+  // Regression test for yet another subtle bug!  The inherited options weren't
+  // being printed by toString.  One day, a real rain will come and wash all
+  // this scummy code off the streets.
+  public static class DerivedBaz extends ExampleBaz {
+    @Option(name = "derived", defaultValue = "defaultDerived")
+    public String derived;
+  }
+
+  @Test
+  public void toStringPrintsInheritedOptionsToo_Duh() throws Exception {
+    DerivedBaz derivedBaz = Options.parse(DerivedBaz.class).getOptions();
+    String derivedBazString = derivedBaz.toString();
+    if (!derivedBazString.contains("derived=defaultDerived") ||
+        !derivedBazString.contains("baz=defaultBaz")) {
+      fail("DerivedBaz.toString() is incorrect: " + derivedBazString);
+    }
+  }
+
+  // Tests for new default value override mechanism
+  public static class CustomOptions extends OptionsBase {
+    @Option(name = "simple",
+        category = "custom",
+        defaultValue = "simple default")
+    public String simple;
+
+    @Option(name = "multipart_name",
+        category = "custom",
+        defaultValue = "multipart default")
+    public String multipartName;
+  }
+
+  public void assertDefaultStringsForCustomOptions() throws OptionsParsingException {
+    CustomOptions options = Options.parse(CustomOptions.class).getOptions();
+    assertEquals("simple default", options.simple);
+    assertEquals("multipart default", options.multipartName);
+  }
+
+  public static class NullTestOptions extends OptionsBase {
+    @Option(name = "simple",
+            defaultValue = "null")
+    public String simple;
+  }
+
+  @Test
+  public void defaultNullStringGivesNull() throws Exception {
+    NullTestOptions options = Options.parse(NullTestOptions.class).getOptions();
+    assertNull(options.simple);
+  }
+
+  public static class ImplicitDependencyOptions extends OptionsBase {
+    @Option(name = "first",
+            implicitRequirements = "--second=second",
+            defaultValue = "null")
+    public String first;
+
+    @Option(name = "second",
+        implicitRequirements = "--third=third",
+        defaultValue = "null")
+    public String second;
+
+    @Option(name = "third",
+        defaultValue = "null")
+    public String third;
+  }
+
+  @Test
+  public void implicitDependencyHasImplicitDependency() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(ImplicitDependencyOptions.class);
+    parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--first=first"));
+    assertEquals("first", parser.getOptions(ImplicitDependencyOptions.class).first);
+    assertEquals("second", parser.getOptions(ImplicitDependencyOptions.class).second);
+    assertEquals("third", parser.getOptions(ImplicitDependencyOptions.class).third);
+  }
+
+  public static class BadImplicitDependencyOptions extends OptionsBase {
+    @Option(name = "first",
+            implicitRequirements = "xxx",
+            defaultValue = "null")
+    public String first;
+  }
+
+  @Test
+  public void badImplicitDependency() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(BadImplicitDependencyOptions.class);
+    try {
+      parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--first=first"));
+    } catch (AssertionError e) {
+      /* Expected error. */
+      return;
+    }
+    fail();
+  }
+
+  public static class BadExpansionOptions extends OptionsBase {
+    @Option(name = "first",
+            expansion = { "xxx" },
+            defaultValue = "null")
+    public Void first;
+  }
+
+  @Test
+  public void badExpansionOptions() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(BadExpansionOptions.class);
+    try {
+      parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--first"));
+    } catch (AssertionError e) {
+      /* Expected error. */
+      return;
+    }
+    fail();
+  }
+
+  public static class ExpansionOptions extends OptionsBase {
+    @Option(name = "first",
+            expansion = { "--second=first" },
+            defaultValue = "null")
+    public Void first;
+
+    @Option(name = "second",
+            defaultValue = "null")
+    public String second;
+  }
+
+  @Test
+  public void overrideExpansionWithExplicit() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(ExpansionOptions.class);
+    parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--first", "--second=second"));
+    ExpansionOptions options = parser.getOptions(ExpansionOptions.class);
+    assertEquals("second", options.second);
+    assertEquals(0, parser.getWarnings().size());
+  }
+
+  @Test
+  public void overrideExplicitWithExpansion() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(ExpansionOptions.class);
+    parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--second=second", "--first"));
+    ExpansionOptions options = parser.getOptions(ExpansionOptions.class);
+    assertEquals("first", options.second);
+  }
+
+  @Test
+  public void overrideWithHigherPriority() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(NullTestOptions.class);
+    parser.parse(OptionPriority.RC_FILE, null, Arrays.asList("--simple=a"));
+    assertEquals("a", parser.getOptions(NullTestOptions.class).simple);
+    parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--simple=b"));
+    assertEquals("b", parser.getOptions(NullTestOptions.class).simple);
+  }
+
+  @Test
+  public void overrideWithLowerPriority() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(NullTestOptions.class);
+    parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--simple=a"));
+    assertEquals("a", parser.getOptions(NullTestOptions.class).simple);
+    parser.parse(OptionPriority.RC_FILE, null, Arrays.asList("--simple=b"));
+    assertEquals("a", parser.getOptions(NullTestOptions.class).simple);
+  }
+
+  @Test
+  public void getOptionValueDescriptionWithNonExistingOption() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(NullTestOptions.class);
+    try {
+      parser.getOptionValueDescription("notexisting");
+      fail();
+    } catch (IllegalArgumentException e) {
+      /* Expected exception. */
+    }
+  }
+
+  @Test
+  public void getOptionValueDescriptionWithoutValue() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(NullTestOptions.class);
+    assertNull(parser.getOptionValueDescription("simple"));
+  }
+
+  @Test
+  public void getOptionValueDescriptionWithValue() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(NullTestOptions.class);
+    parser.parse(OptionPriority.COMMAND_LINE, "my description",
+        Arrays.asList("--simple=abc"));
+    OptionValueDescription result = parser.getOptionValueDescription("simple");
+    assertNotNull(result);
+    assertEquals("simple", result.getName());
+    assertEquals("abc", result.getValue());
+    assertEquals(OptionPriority.COMMAND_LINE, result.getPriority());
+    assertEquals("my description", result.getSource());
+    assertNull(result.getImplicitDependant());
+    assertFalse(result.isImplicitDependency());
+    assertNull(result.getExpansionParent());
+    assertFalse(result.isExpansion());
+  }
+
+  public static class ImplicitDependencyWarningOptions extends OptionsBase {
+    @Option(name = "first",
+            implicitRequirements = "--second=second",
+            defaultValue = "null")
+    public String first;
+
+    @Option(name = "second",
+        defaultValue = "null")
+    public String second;
+
+    @Option(name = "third",
+            implicitRequirements = "--second=third",
+            defaultValue = "null")
+    public String third;
+  }
+
+  @Test
+  public void warningForImplicitOverridingExplicitOption() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(ImplicitDependencyWarningOptions.class);
+    parser.parse("--second=second", "--first=first");
+    assertThat(parser.getWarnings())
+        .containsExactly("Option 'second' is implicitly defined by "
+                         + "option 'first'; the implicitly set value overrides the previous one");
+  }
+
+  @Test
+  public void warningForExplicitOverridingImplicitOption() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(ImplicitDependencyWarningOptions.class);
+    parser.parse("--first=first");
+    assertThat(parser.getWarnings()).isEmpty();
+    parser.parse("--second=second");
+    assertThat(parser.getWarnings())
+        .containsExactly("A new value for option 'second' overrides a"
+                         + " previous implicit setting of that option by option 'first'");
+  }
+
+  @Test
+  public void warningForExplicitOverridingImplicitOptionInSameCall() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(ImplicitDependencyWarningOptions.class);
+    parser.parse("--first=first", "--second=second");
+    assertThat(parser.getWarnings())
+        .containsExactly("Option 'second' is implicitly defined by "
+                         + "option 'first'; the implicitly set value overrides the previous one");
+  }
+
+  @Test
+  public void warningForImplicitOverridingImplicitOption() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(ImplicitDependencyWarningOptions.class);
+    parser.parse("--first=first");
+    assertThat(parser.getWarnings()).isEmpty();
+    parser.parse("--third=third");
+    assertThat(parser.getWarnings())
+        .containsExactly("Option 'second' is implicitly defined by both "
+                         + "option 'first' and option 'third'");
+  }
+
+  public static class WarningOptions extends OptionsBase {
+    @Deprecated
+    @Option(name = "first",
+            defaultValue = "null")
+    public Void first;
+
+    @Deprecated
+    @Option(name = "second",
+            allowMultiple = true,
+            defaultValue = "null")
+    public List<String> second;
+
+    @Deprecated
+    @Option(name = "third",
+            expansion = "--fourth=true",
+            abbrev = 't',
+            defaultValue = "null")
+    public Void third;
+
+    @Option(name = "fourth",
+            defaultValue = "false")
+    public boolean fourth;
+  }
+
+  @Test
+  public void deprecationWarning() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(WarningOptions.class);
+    parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--first"));
+    assertEquals(Arrays.asList("Option 'first' is deprecated"), parser.getWarnings());
+  }
+
+  @Test
+  public void deprecationWarningForListOption() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(WarningOptions.class);
+    parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--second=a"));
+    assertEquals(Arrays.asList("Option 'second' is deprecated"), parser.getWarnings());
+  }
+
+  @Test
+  public void deprecationWarningForExpansionOption() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(WarningOptions.class);
+    parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--third"));
+    assertEquals(Arrays.asList("Option 'third' is deprecated"), parser.getWarnings());
+    assertTrue(parser.getOptions(WarningOptions.class).fourth);
+  }
+
+  @Test
+  public void deprecationWarningForAbbreviatedExpansionOption() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(WarningOptions.class);
+    parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("-t"));
+    assertEquals(Arrays.asList("Option 'third' is deprecated"), parser.getWarnings());
+    assertTrue(parser.getOptions(WarningOptions.class).fourth);
+  }
+
+  public static class NewWarningOptions extends OptionsBase {
+    @Option(name = "first",
+            defaultValue = "null",
+            deprecationWarning = "it's gone")
+    public Void first;
+
+    @Option(name = "second",
+            allowMultiple = true,
+            defaultValue = "null",
+            deprecationWarning = "sorry, no replacement")
+    public List<String> second;
+
+    @Option(name = "third",
+            expansion = "--fourth=true",
+            defaultValue = "null",
+            deprecationWarning = "use --forth instead")
+    public Void third;
+
+    @Option(name = "fourth",
+            defaultValue = "false")
+    public boolean fourth;
+  }
+
+  @Test
+  public void newDeprecationWarning() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(NewWarningOptions.class);
+    parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--first"));
+    assertEquals(Arrays.asList("Option 'first' is deprecated: it's gone"), parser.getWarnings());
+  }
+
+  @Test
+  public void newDeprecationWarningForListOption() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(NewWarningOptions.class);
+    parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--second=a"));
+    assertEquals(Arrays.asList("Option 'second' is deprecated: sorry, no replacement"),
+        parser.getWarnings());
+  }
+
+  @Test
+  public void newDeprecationWarningForExpansionOption() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(NewWarningOptions.class);
+    parser.parse(OptionPriority.COMMAND_LINE, null, Arrays.asList("--third"));
+    assertEquals(Arrays.asList("Option 'third' is deprecated: use --forth instead"),
+        parser.getWarnings());
+    assertTrue(parser.getOptions(NewWarningOptions.class).fourth);
+  }
+
+  public static class ExpansionWarningOptions extends OptionsBase {
+    @Option(name = "first",
+            expansion = "--second=other",
+            defaultValue = "null")
+    public Void first;
+
+    @Option(name = "second",
+            defaultValue = "null")
+    public String second;
+  }
+
+  @Test
+  public void warningForExpansionOverridingExplicitOption() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(ExpansionWarningOptions.class);
+    parser.parse("--second=second", "--first");
+    assertThat(parser.getWarnings())
+        .containsExactly("The option 'first' was expanded and now overrides a "
+                         + "previous explicitly specified option 'second'");
+  }
+
+  public static class InvalidOptionConverter extends OptionsBase {
+    @Option(name = "foo",
+            converter = StringConverter.class,
+            defaultValue = "1")
+    public Integer foo;
+  }
+
+  @Test
+  public void errorForInvalidOptionConverter() throws Exception {
+    try {
+      OptionsParser.newOptionsParser(InvalidOptionConverter.class);
+    } catch (AssertionError e) {
+      // Expected exception
+      return;
+    }
+    fail();
+  }
+
+  public static class InvalidListOptionConverter extends OptionsBase {
+    @Option(name = "foo",
+            converter = StringConverter.class,
+            defaultValue = "1",
+            allowMultiple = true)
+    public List<Integer> foo;
+  }
+
+  @Test
+  public void errorForInvalidListOptionConverter() throws Exception {
+    try {
+      OptionsParser.newOptionsParser(InvalidListOptionConverter.class);
+    } catch (AssertionError e) {
+      // Expected exception
+      return;
+    }
+    fail();
+  }
+
+  // This test is here to make sure that nobody accidentally changes the
+  // order of the enum values and breaks the implicit assumptions elsewhere
+  // in the code.
+  @Test
+  public void optionPrioritiesAreCorrectlyOrdered() throws Exception {
+    assertEquals(5, OptionPriority.values().length);
+    assertEquals(-1, OptionPriority.DEFAULT.compareTo(OptionPriority.COMPUTED_DEFAULT));
+    assertEquals(-1, OptionPriority.COMPUTED_DEFAULT.compareTo(OptionPriority.RC_FILE));
+    assertEquals(-1, OptionPriority.RC_FILE.compareTo(OptionPriority.COMMAND_LINE));
+    assertEquals(-1, OptionPriority.COMMAND_LINE.compareTo(OptionPriority.SOFTWARE_REQUIREMENT));
+  }
+
+  public static class IntrospectionExample extends OptionsBase {
+    @Option(name = "alpha",
+            category = "one",
+            defaultValue = "alpha")
+    public String alpha;
+
+    @Option(name = "beta",
+            category = "one",
+            defaultValue = "beta")
+    public String beta;
+
+    @Option(name = "gamma",
+        category = "undocumented",
+        defaultValue = "gamma")
+    public String gamma;
+
+    @Option(name = "delta",
+        category = "undocumented",
+        defaultValue = "delta")
+    public String delta;
+
+    @Option(name = "echo",
+        category = "hidden",
+        defaultValue = "echo")
+    public String echo;
+  }
+
+  @Test
+  public void asListOfUnparsedOptions() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(IntrospectionExample.class);
+    parser.parse(OptionPriority.COMMAND_LINE, "source",
+        Arrays.asList("--alpha=one", "--gamma=two", "--echo=three"));
+    List<UnparsedOptionValueDescription> result = parser.asListOfUnparsedOptions();
+    assertNotNull(result);
+    assertEquals(3, result.size());
+
+    assertEquals("alpha", result.get(0).getName());
+    assertEquals(true, result.get(0).isDocumented());
+    assertEquals(false, result.get(0).isHidden());
+    assertEquals("one", result.get(0).getUnparsedValue());
+    assertEquals("source", result.get(0).getSource());
+    assertEquals(OptionPriority.COMMAND_LINE, result.get(0).getPriority());
+
+    assertEquals("gamma", result.get(1).getName());
+    assertEquals(false, result.get(1).isDocumented());
+    assertEquals(false, result.get(1).isHidden());
+    assertEquals("two", result.get(1).getUnparsedValue());
+    assertEquals("source", result.get(1).getSource());
+    assertEquals(OptionPriority.COMMAND_LINE, result.get(1).getPriority());
+
+    assertEquals("echo", result.get(2).getName());
+    assertEquals(false, result.get(2).isDocumented());
+    assertEquals(true, result.get(2).isHidden());
+    assertEquals("three", result.get(2).getUnparsedValue());
+    assertEquals("source", result.get(2).getSource());
+    assertEquals(OptionPriority.COMMAND_LINE, result.get(2).getPriority());
+  }
+
+  @Test
+  public void asListOfExplicitOptions() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(IntrospectionExample.class);
+    parser.parse(OptionPriority.COMMAND_LINE, "source",
+        Arrays.asList("--alpha=one", "--gamma=two"));
+    List<UnparsedOptionValueDescription> result = parser.asListOfExplicitOptions();
+    assertNotNull(result);
+    assertEquals(2, result.size());
+
+    assertEquals("alpha", result.get(0).getName());
+    assertEquals(true, result.get(0).isDocumented());
+    assertEquals("one", result.get(0).getUnparsedValue());
+    assertEquals("source", result.get(0).getSource());
+    assertEquals(OptionPriority.COMMAND_LINE, result.get(0).getPriority());
+
+    assertEquals("gamma", result.get(1).getName());
+    assertEquals(false, result.get(1).isDocumented());
+    assertEquals("two", result.get(1).getUnparsedValue());
+    assertEquals("source", result.get(1).getSource());
+    assertEquals(OptionPriority.COMMAND_LINE, result.get(1).getPriority());
+  }
+
+  private void assertOptionValue(String expectedName, Object expectedValue,
+      OptionPriority expectedPriority, String expectedSource,
+      OptionValueDescription actual) {
+    assertNotNull(actual);
+    assertEquals(expectedName, actual.getName());
+    assertEquals(expectedValue, actual.getValue());
+    assertEquals(expectedPriority, actual.getPriority());
+    assertEquals(expectedSource, actual.getSource());
+  }
+
+  @Test
+  public void asListOfEffectiveOptions() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(IntrospectionExample.class);
+    parser.parse(OptionPriority.COMMAND_LINE, "source",
+        Arrays.asList("--alpha=one", "--gamma=two"));
+    List<OptionValueDescription> result = parser.asListOfEffectiveOptions();
+    assertNotNull(result);
+    assertEquals(5, result.size());
+    HashMap<String,OptionValueDescription> map = new HashMap<String,OptionValueDescription>();
+    for (OptionValueDescription description : result) {
+      map.put(description.getName(), description);
+    }
+
+    assertOptionValue("alpha", "one", OptionPriority.COMMAND_LINE, "source",
+        map.get("alpha"));
+    assertOptionValue("beta", "beta", OptionPriority.DEFAULT, null,
+        map.get("beta"));
+    assertOptionValue("gamma", "two", OptionPriority.COMMAND_LINE, "source",
+        map.get("gamma"));
+    assertOptionValue("delta", "delta", OptionPriority.DEFAULT, null,
+        map.get("delta"));
+    assertOptionValue("echo", "echo", OptionPriority.DEFAULT, null,
+        map.get("echo"));
+  }
+
+  // Regression tests for bug:
+  // "--option from blazerc unexpectedly overrides --option from command line"
+  public static class ListExample extends OptionsBase {
+    @Option(name = "alpha",
+            converter = StringConverter.class,
+            allowMultiple = true,
+            defaultValue = "null")
+    public List<String> alpha;
+  }
+
+  @Test
+  public void overrideListOptions() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(ListExample.class);
+    parser.parse(OptionPriority.COMMAND_LINE, "a", Arrays.asList("--alpha=two"));
+    parser.parse(OptionPriority.RC_FILE, "b", Arrays.asList("--alpha=one"));
+    assertEquals(Arrays.asList("one", "two"), parser.getOptions(ListExample.class).alpha);
+  }
+
+  public static class CommaSeparatedOptionsExample extends OptionsBase {
+    @Option(name = "alpha",
+            converter = CommaSeparatedOptionListConverter.class,
+            allowMultiple = true,
+            defaultValue = "null")
+    public List<String> alpha;
+  }
+
+  @Test
+  public void commaSeparatedOptionsWithAllowMultiple() throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(CommaSeparatedOptionsExample.class);
+    parser.parse(OptionPriority.COMMAND_LINE, "a", Arrays.asList("--alpha=one",
+        "--alpha=two,three"));
+    assertEquals(Arrays.asList("one", "two", "three"),
+        parser.getOptions(CommaSeparatedOptionsExample.class).alpha);
+  }
+
+  public static class IllegalListTypeExample extends OptionsBase {
+    @Option(name = "alpha",
+            converter = CommaSeparatedOptionListConverter.class,
+            allowMultiple = true,
+            defaultValue = "null")
+    public List<Integer> alpha;
+  }
+
+  @Test
+  public void illegalListType() throws Exception {
+    try {
+      OptionsParser.newOptionsParser(IllegalListTypeExample.class);
+    } catch (AssertionError e) {
+      // Expected exception
+      return;
+    }
+    fail();
+  }
+
+  public static class Yesterday extends OptionsBase {
+
+    @Option(name = "a",
+            defaultValue = "a")
+    public String a;
+
+    @Option(name = "b",
+            defaultValue = "b")
+    public String b;
+
+    @Option(name = "c",
+            defaultValue = "null",
+            expansion = {"--a=0"})
+    public Void c;
+
+    @Option(name = "d",
+            defaultValue = "null",
+            allowMultiple = true)
+    public List<String> d;
+
+    @Option(name = "e",
+            defaultValue = "null",
+            implicitRequirements = { "--a==1" })
+    public String e;
+
+    @Option(name = "f",
+            defaultValue = "null",
+            implicitRequirements = { "--b==1" })
+    public String f;
+
+    @Option(name = "g",
+            abbrev = 'h',
+            defaultValue = "false")
+    public boolean g;
+  }
+
+  public static List<String> canonicalize(Class<? extends OptionsBase> optionsClass, String... args)
+      throws OptionsParsingException {
+    return OptionsParser.canonicalize(ImmutableList.<Class<? extends OptionsBase>>of(optionsClass),
+        Arrays.asList(args));
+  }
+
+  @Test
+  public void canonicalizeEasy() throws Exception {
+    assertEquals(Arrays.asList("--a=x"), canonicalize(Yesterday.class, "--a=x"));
+  }
+
+  @Test
+  public void canonicalizeSkipDuplicate() throws Exception {
+    assertEquals(Arrays.asList("--a=x"), canonicalize(Yesterday.class, "--a=y", "--a=x"));
+  }
+
+  @Test
+  public void canonicalizeExpands() throws Exception {
+    assertEquals(Arrays.asList("--a=0"), canonicalize(Yesterday.class, "--c"));
+  }
+
+  @Test
+  public void canonicalizeExpansionOverridesExplicit() throws Exception {
+    assertEquals(Arrays.asList("--a=0"), canonicalize(Yesterday.class, "--a=x", "--c"));
+  }
+
+  @Test
+  public void canonicalizeExplicitOverridesExpansion() throws Exception {
+    assertEquals(Arrays.asList("--a=x"), canonicalize(Yesterday.class, "--c", "--a=x"));
+  }
+
+  @Test
+  public void canonicalizeSorts() throws Exception {
+    assertEquals(Arrays.asList("--a=x", "--b=y"), canonicalize(Yesterday.class, "--b=y", "--a=x"));
+  }
+
+  @Test
+  public void canonicalizeImplicitDepsAtEnd() throws Exception {
+    assertEquals(Arrays.asList("--a=x", "--e=y"), canonicalize(Yesterday.class, "--e=y", "--a=x"));
+  }
+
+  @Test
+  public void canonicalizeImplicitDepsSkipsDuplicate() throws Exception {
+    assertEquals(Arrays.asList("--e=y"), canonicalize(Yesterday.class, "--e=x", "--e=y"));
+  }
+
+  @Test
+  public void canonicalizeDoesNotSortImplicitDeps() throws Exception {
+    assertEquals(Arrays.asList("--a=x", "--f=z", "--e=y"),
+        canonicalize(Yesterday.class, "--f=z", "--e=y", "--a=x"));
+  }
+
+  @Test
+  public void canonicalizeDoesNotSkipAllowMultiple() throws Exception {
+    assertEquals(Arrays.asList("--d=a", "--d=b"),
+        canonicalize(Yesterday.class, "--d=a", "--d=b"));
+  }
+
+  @Test
+  public void canonicalizeReplacesAbbrevWithName() throws Exception {
+    assertEquals(Arrays.asList("--g=1"),
+        canonicalize(Yesterday.class, "-h"));
+  }
+
+  public static class LongValueExample extends OptionsBase {
+    @Option(name = "longval",
+            defaultValue = "2147483648")
+    public long longval;
+
+    @Option(name = "intval",
+            defaultValue = "2147483647")
+    public int intval;
+  }
+
+  @Test
+  public void parseLong() throws OptionsParsingException {
+    OptionsParser parser = newOptionsParser(LongValueExample.class);
+    parser.parse("");
+    LongValueExample result = parser.getOptions(LongValueExample.class);
+    assertEquals(2147483648L, result.longval);
+    assertEquals(2147483647, result.intval);
+
+    parser.parse("--longval", Long.toString(Long.MIN_VALUE));
+    result = parser.getOptions(LongValueExample.class);
+    assertEquals(Long.MIN_VALUE, result.longval);
+
+    try {
+      parser.parse("--intval=2147483648");
+      fail();
+    } catch (OptionsParsingException e) {
+    }
+
+    parser.parse("--longval", "100");
+    result = parser.getOptions(LongValueExample.class);
+    assertEquals(100, result.longval);
+  }
+}
diff --git a/src/test/java/com/google/devtools/common/options/OptionsTest.java b/src/test/java/com/google/devtools/common/options/OptionsTest.java
new file mode 100644
index 0000000..700e26b
--- /dev/null
+++ b/src/test/java/com/google/devtools/common/options/OptionsTest.java
@@ -0,0 +1,500 @@
+// Copyright 2014 Google Inc. 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.common.options;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Arrays.asList;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+
+/**
+ * Test for {@link Options}.
+ */
+@RunWith(JUnit4.class)
+public class OptionsTest {
+
+  private static final String[] NO_ARGS = {};
+
+  public static class HttpOptions extends OptionsBase {
+
+    @Option(name = "host",
+            defaultValue = "www.google.com",
+            help = "The URL at which the server will be running.")
+    public String host;
+
+    @Option(name = "port",
+            abbrev = 'p',
+            defaultValue = "80",
+            help = "The port at which the server will be running.")
+    public int port;
+
+    @Option(name = "debug",
+            abbrev = 'd',
+            defaultValue = "false",
+            help = "debug")
+    public boolean isDebugging;
+
+    @Option(name = "tristate",
+        abbrev = 't',
+        defaultValue = "auto",
+        help = "tri-state option returning auto by default")
+    public TriState triState;
+
+    @Option(name = "special",
+            defaultValue = "null",
+            expansion = { "--host=special.google.com", "--port=8080"})
+    public Void special;
+  }
+
+  @Test
+  public void paragraphFill() throws Exception {
+    // TODO(bazel-team): don't include trailing space after last word in line.
+    String input = "The quick brown fox jumps over the lazy dog.";
+
+    assertEquals("  The quick \n  brown fox \n  jumps over \n  the lazy \n"
+                 + "  dog.",
+                 OptionsUsage.paragraphFill(input, 2, 13));
+    assertEquals("   The quick brown \n   fox jumps over \n   the lazy dog.",
+                 OptionsUsage.paragraphFill(input, 3, 19));
+
+    String input2 = "The quick brown fox jumps\nAnother paragraph.";
+    assertEquals("  The quick brown fox \n  jumps\n  Another paragraph.",
+                 OptionsUsage.paragraphFill(input2, 2, 23));
+  }
+
+  @Test
+  public void getsDefaults() throws OptionsParsingException {
+    Options<HttpOptions> options = Options.parse(HttpOptions.class, NO_ARGS);
+    String[] remainingArgs = options.getRemainingArgs();
+    HttpOptions webFlags = options.getOptions();
+
+    assertEquals("www.google.com", webFlags.host);
+    assertEquals(80, webFlags.port);
+    assertEquals(false, webFlags.isDebugging);
+    assertEquals(TriState.AUTO, webFlags.triState);
+    assertEquals(0, remainingArgs.length);
+  }
+
+  @Test
+  public void objectMethods() throws OptionsParsingException {
+    String[] args = { "--host", "foo", "--port", "80" };
+    HttpOptions left =
+        Options.parse(HttpOptions.class, args).getOptions();
+    HttpOptions likeLeft =
+        Options.parse(HttpOptions.class, args).getOptions();
+    String [] rightArgs = {"--host", "other", "--port", "90" };
+    HttpOptions right =
+        Options.parse(HttpOptions.class, rightArgs).getOptions();
+
+    String toString = left.toString();
+    // Don't rely on Set.toString iteration order:
+    assertTrue(toString.startsWith(
+                   "com.google.devtools.common.options.OptionsTest"
+                   + "$HttpOptions{"));
+    assertTrue(toString.contains("host=foo"));
+    assertTrue(toString.contains("port=80"));
+    assertTrue(toString.endsWith("}"));
+
+    assertTrue(left.equals(left));
+    assertTrue(left.toString().equals(likeLeft.toString()));
+    assertTrue(left.equals(likeLeft));
+    assertTrue(likeLeft.equals(left));
+    assertFalse(left.equals(right));
+    assertFalse(right.equals(left));
+    assertFalse(left.equals(null));
+    assertFalse(likeLeft.equals(null));
+    assertEquals(likeLeft.hashCode(), likeLeft.hashCode());
+    assertEquals(left.hashCode(), likeLeft.hashCode());
+    // Strictly speaking this is not required for hashCode to be correct,
+    // but a good hashCode should be different at least for some values. So,
+    // we're making sure that at least this particular pair of inputs yields
+    // different values.
+    assertFalse(left.hashCode() == right.hashCode());
+  }
+
+  @Test
+  public void equals() throws OptionsParsingException {
+    String[] args = { "--host", "foo", "--port", "80" };
+    HttpOptions options1 =  Options.parse(HttpOptions.class, args).getOptions();
+
+    String[] args2 = { "-p", "80", "--host", "foo" };
+    HttpOptions options2 =  Options.parse(HttpOptions.class, args2).getOptions();
+    assertEquals("order/abbreviations shouldn't matter", options1, options2);
+
+    assertEquals("explicitly setting a default shouldn't matter",
+        Options.parse(HttpOptions.class, "--port", "80").getOptions(),
+        Options.parse(HttpOptions.class).getOptions());
+
+    assertThat(Options.parse(HttpOptions.class, "--port", "3").getOptions())
+        .isNotEqualTo(Options.parse(HttpOptions.class).getOptions());
+  }
+
+  @Test
+  public void getsFlagsProvidedInArguments()
+      throws OptionsParsingException {
+    String[] args = {"--host", "google.com",
+                     "-p", "8080",  // short form
+                     "--debug"};
+    Options<HttpOptions> options = Options.parse(HttpOptions.class, args);
+    String[] remainingArgs = options.getRemainingArgs();
+    HttpOptions webFlags = options.getOptions();
+
+    assertEquals("google.com", webFlags.host);
+    assertEquals(8080, webFlags.port);
+    assertEquals(true, webFlags.isDebugging);
+    assertEquals(0, remainingArgs.length);
+  }
+
+  @Test
+  public void getsFlagsProvidedWithEquals() throws OptionsParsingException {
+    String[] args = {"--host=google.com",
+                     "--port=8080",
+                     "--debug"};
+    Options<HttpOptions> options = Options.parse(HttpOptions.class, args);
+    String[] remainingArgs = options.getRemainingArgs();
+    HttpOptions webFlags = options.getOptions();
+
+    assertEquals("google.com", webFlags.host);
+    assertEquals(8080, webFlags.port);
+    assertEquals(true, webFlags.isDebugging);
+    assertEquals(0, remainingArgs.length);
+  }
+
+  @Test
+  public void booleanNo() throws OptionsParsingException {
+    Options<HttpOptions> options =
+        Options.parse(HttpOptions.class, new String[]{"--nodebug", "--notristate"});
+    HttpOptions webFlags = options.getOptions();
+    assertEquals(false, webFlags.isDebugging);
+    assertEquals(TriState.NO, webFlags.triState);
+  }
+
+  @Test
+  public void booleanNoUnderscore() throws OptionsParsingException {
+    Options<HttpOptions> options =
+        Options.parse(HttpOptions.class, new String[]{"--no_debug", "--no_tristate"});
+    HttpOptions webFlags = options.getOptions();
+    assertEquals(false, webFlags.isDebugging);
+    assertEquals(TriState.NO, webFlags.triState);
+  }
+
+  @Test
+  public void booleanAbbrevMinus() throws OptionsParsingException {
+    Options<HttpOptions> options =
+        Options.parse(HttpOptions.class, new String[]{"-d-", "-t-"});
+    HttpOptions webFlags = options.getOptions();
+    assertEquals(false, webFlags.isDebugging);
+    assertEquals(TriState.NO, webFlags.triState);
+  }
+
+  @Test
+  public void boolean0() throws OptionsParsingException {
+    Options<HttpOptions> options =
+        Options.parse(HttpOptions.class, new String[]{"--debug=0", "--tristate=0"});
+    HttpOptions webFlags = options.getOptions();
+    assertEquals(false, webFlags.isDebugging);
+    assertEquals(TriState.NO, webFlags.triState);
+  }
+
+  @Test
+  public void boolean1() throws OptionsParsingException {
+    Options<HttpOptions> options =
+        Options.parse(HttpOptions.class, new String[]{"--debug=1", "--tristate=1"});
+    HttpOptions webFlags = options.getOptions();
+    assertEquals(true, webFlags.isDebugging);
+    assertEquals(TriState.YES, webFlags.triState);
+  }
+
+  @Test
+  public void retainsStuffThatsNotOptions() throws OptionsParsingException {
+    String[] args = {"these", "aint", "options"};
+    Options<HttpOptions> options = Options.parse(HttpOptions.class, args);
+    String[] remainingArgs = options.getRemainingArgs();
+    assertEquals(asList(args), asList(remainingArgs));
+  }
+
+  @Test
+  public void retainsStuffThatsNotComplexOptions()
+      throws OptionsParsingException {
+    String[] args = {"--host", "google.com",
+                     "notta",
+                     "--port=8080",
+                     "option",
+                     "--debug=true"};
+    String[] notoptions = {"notta", "option" };
+    Options<HttpOptions> options = Options.parse(HttpOptions.class, args);
+    String[] remainingArgs = options.getRemainingArgs();
+    assertEquals(asList(notoptions), asList(remainingArgs));
+  }
+
+  @Test
+  public void wontParseUnknownOptions() {
+    String [] args = { "--unknown", "--other=23", "--options" };
+    try {
+      Options.parse(HttpOptions.class, args);
+      fail();
+    } catch (OptionsParsingException e) {
+      assertEquals("Unrecognized option: --unknown", e.getMessage());
+    }
+  }
+
+  @Test
+  public void requiresOptionValue() {
+    String[] args = {"--port"};
+    try {
+      Options.parse(HttpOptions.class, args);
+      fail();
+    } catch (OptionsParsingException e) {
+      assertEquals("Expected value after --port", e.getMessage());
+    }
+  }
+
+  @Test
+  public void handlesDuplicateOptions_full() throws Exception {
+    String[] args = {"--port=80", "--port", "81"};
+    Options<HttpOptions> options = Options.parse(HttpOptions.class, args);
+    HttpOptions webFlags = options.getOptions();
+    assertEquals(81, webFlags.port);
+  }
+
+  @Test
+  public void handlesDuplicateOptions_abbrev() throws Exception {
+    String[] args = {"--port=80", "-p", "81"};
+    Options<HttpOptions> options = Options.parse(HttpOptions.class, args);
+    HttpOptions webFlags = options.getOptions();
+    assertEquals(81, webFlags.port);
+  }
+
+  @Test
+  public void duplicateOptionsOkWithSameValues() throws Exception {
+    // These would throw OptionsParsingException if they failed.
+    Options.parse(HttpOptions.class,"--port=80", "--port", "80");
+    Options.parse(HttpOptions.class, "--port=80", "-p", "80");
+  }
+
+  @Test
+  public void isPickyAboutBooleanValues() {
+    try {
+      Options.parse(HttpOptions.class, new String[]{"--debug=not_a_boolean"});
+      fail();
+    } catch (OptionsParsingException e) {
+      assertEquals("While parsing option --debug=not_a_boolean: "
+                   + "\'not_a_boolean\' is not a boolean", e.getMessage());
+    }
+  }
+
+  @Test
+  public void isPickyAboutBooleanNos() {
+    try {
+      Options.parse(HttpOptions.class, new String[]{"--nodebug=1"});
+      fail();
+    } catch (OptionsParsingException e) {
+      assertEquals("Unexpected value after boolean option: --nodebug=1", e.getMessage());
+    }
+  }
+
+  @Test
+  public void usageForBuiltinTypes() {
+    String usage = Options.getUsage(HttpOptions.class);
+    // We can't rely on the option ordering.
+    assertTrue(usage.contains(
+            "  --[no]debug [-d] (a boolean; default: \"false\")\n" +
+            "    debug"));
+    assertTrue(usage.contains(
+            "  --host (a string; default: \"www.google.com\")\n" +
+            "    The URL at which the server will be running."));
+    assertTrue(usage.contains(
+            "  --port [-p] (an integer; default: \"80\")\n" +
+            "    The port at which the server will be running."));
+    assertTrue(usage.contains(
+            "  --special\n" +
+            "    Expands to: --host=special.google.com --port=8080"));
+    assertTrue(usage.contains(
+        "  --[no]tristate [-t] (a tri-state (auto, yes, no); default: \"auto\")\n" +
+        "    tri-state option returning auto by default"));
+  }
+
+  public static class NullTestOptions extends OptionsBase {
+    @Option(name = "host",
+            defaultValue = "null",
+            help = "The URL at which the server will be running.")
+    public String host;
+
+    @Option(name = "none",
+        defaultValue = "null",
+        expansion = {"--host=www.google.com"},
+        help = "An expanded option.")
+    public Void none;
+  }
+
+  @Test
+  public void usageForNullDefault() {
+    String usage = Options.getUsage(NullTestOptions.class);
+    assertTrue(usage.contains(
+            "  --host (a string; default: see description)\n" +
+            "    The URL at which the server will be running."));
+    assertTrue(usage.contains(
+            "  --none\n" +
+            "    An expanded option.\n" +
+            "    Expands to: --host=www.google.com"));
+  }
+
+  public static class MyURLConverter implements Converter<URL> {
+
+    @Override
+    public URL convert(String input) throws OptionsParsingException {
+      try {
+        return new URL(input);
+      } catch (MalformedURLException e) {
+        throw new OptionsParsingException("Could not convert '" + input + "': "
+                                          + e.getMessage());
+      }
+    }
+
+    @Override
+    public String getTypeDescription() {
+      return "a url";
+    }
+
+  }
+
+  public static class UsesCustomConverter extends OptionsBase {
+
+    @Option(name = "url",
+            defaultValue = "http://www.google.com/",
+            converter = MyURLConverter.class)
+    public URL url;
+
+  }
+
+  @Test
+  public void customConverter() throws Exception {
+    Options<UsesCustomConverter> options =
+      Options.parse(UsesCustomConverter.class, new String[0]);
+    URL expected = new URL("http://www.google.com/");
+    assertEquals(expected, options.getOptions().url);
+  }
+
+  @Test
+  public void customConverterThrowsException() throws Exception {
+    String[] args = {"--url=a_malformed:url"};
+    try {
+      Options.parse(UsesCustomConverter.class, args);
+      fail();
+    } catch (OptionsParsingException e) {
+      assertEquals("While parsing option --url=a_malformed:url: "
+                   + "Could not convert 'a_malformed:url': "
+                   + "no protocol: a_malformed:url", e.getMessage());
+    }
+  }
+
+  @Test
+  public void usageWithCustomConverter() {
+    assertEquals(
+        "  --url (a url; default: \"http://www.google.com/\")\n",
+        Options.getUsage(UsesCustomConverter.class));
+  }
+
+  @Test
+  public void unknownBooleanOption() {
+    try {
+      Options.parse(HttpOptions.class, new String[]{"--no-debug"});
+      fail();
+    } catch (OptionsParsingException e) {
+      assertEquals("Unrecognized option: --no-debug", e.getMessage());
+    }
+  }
+
+  public static class J extends OptionsBase {
+    @Option(name = "j", defaultValue = "null")
+    public String string;
+  }
+  @Test
+  public void nullDefaultForReferenceTypeOption() throws Exception {
+    J options = Options.parse(J.class, NO_ARGS).getOptions();
+    assertNull(options.string);
+  }
+
+  public static class K extends OptionsBase {
+    @Option(name = "1", defaultValue = "null")
+    public int int1;
+  }
+  @Test
+  public void nullDefaultForPrimitiveTypeOption() throws Exception {
+    // defaultValue() = "null" is not treated specially for primitive types, so
+    // we get an NumberFormatException from the converter (not a
+    // ClassCastException from casting null to int), just as we would for any
+    // other non-integer-literal string default.
+    try {
+      Options.parse(K.class, NO_ARGS).getOptions();
+      fail();
+    } catch (IllegalStateException e) {
+      assertEquals("OptionsParsingException while retrieving default for "
+                   + "int1: 'null' is not an int",
+                   e.getMessage());
+    }
+  }
+
+  @Test
+  public void nullIsntInterpretedSpeciallyExceptAsADefaultValue()
+      throws Exception {
+    HttpOptions options =
+        Options.parse(HttpOptions.class,
+                      new String[] { "--host", "null" }).getOptions();
+    assertEquals("null", options.host);
+  }
+
+  @Test
+  public void nonDecimalRadicesForIntegerOptions() throws Exception {
+    Options<HttpOptions> options =
+        Options.parse(HttpOptions.class, new String[] { "--port", "0x51"});
+    assertEquals(81, options.getOptions().port);
+  }
+
+  @Test
+  public void expansionOptionSimple() throws Exception {
+    Options<HttpOptions> options =
+      Options.parse(HttpOptions.class, new String[] {"--special"});
+    assertEquals("special.google.com", options.getOptions().host);
+    assertEquals(8080, options.getOptions().port);
+  }
+
+  @Test
+  public void expansionOptionOverride() throws Exception {
+    Options<HttpOptions> options =
+      Options.parse(HttpOptions.class, new String[] {"--port=90", "--special", "--host=foo"});
+    assertEquals("foo", options.getOptions().host);
+    assertEquals(8080, options.getOptions().port);
+  }
+
+  @Test
+  public void expansionOptionEquals() throws Exception {
+    Options<HttpOptions> options1 =
+      Options.parse(HttpOptions.class, new String[] { "--host=special.google.com", "--port=8080"});
+    Options<HttpOptions> options2 =
+      Options.parse(HttpOptions.class, new String[] { "--special" });
+    assertEquals(options1.getOptions(), options2.getOptions());
+  }
+}