Merge "Use imported DocsUrlBehavior"
diff --git a/Documentation/dev-cla.txt b/Documentation/dev-cla.txt
index a5a9932..2aa459c 100644
--- a/Documentation/dev-cla.txt
+++ b/Documentation/dev-cla.txt
@@ -1,7 +1,7 @@
 :linkattrs:
 = Gerrit Code Review - Contributor License Agreement
 
-In order to link::dev-community.html#how-to-contribute[contribute] to
+In order to link:dev-community.html#how-to-contribute[contribute] to
 Gerrit a Contributor License Agreement must be completed before
 contributions are accepted. To view and accept the agreements do the
 following:
diff --git a/Documentation/dev-community.txt b/Documentation/dev-community.txt
index 5892253..53829c9 100644
--- a/Documentation/dev-community.txt
+++ b/Documentation/dev-community.txt
@@ -18,10 +18,7 @@
 * link:dev-design.html[System Design]
 * Processes
 ** link:dev-processes.html#project-governance[Project Governance / Engineering Steering Committee]
-** link:dev-contributing.html#contribution-processes[Contribution Processes]
-*** link:dev-contributing.html#lightweight-contribution-process[Lightweight Contribution Process]
-*** link:dev-contributing.html#design-driven-contribution-process[Design-Driven Contribution Process]
-*** link:dev-contributing.html#mentorship[Mentorship]
+** link:#how-to-contribute[Contribution Processes]
 ** link:dev-design-docs.html#review[Design doc reviews]
 ** link:dev-processes.html#dev-in-stable-branches[Development in stable branches]
 ** link:dev-processes.html#backporting[Backporting to stable branches]
@@ -38,7 +35,7 @@
 ** link:dev-roles.html#release-manager[Release Manager]
 
 [[how-to-contribute]]
-== How to contribute?
+== How to Contribute
 * link:dev-cla.html[Contributor License Agreement]
 * link:dev-contributing.html#contribution-processes[Contribution Processes]
 ** link:dev-contributing.html#lightweight-contribution-process[Lightweight Contribution Process]
diff --git a/Documentation/dev-e2e-tests.txt b/Documentation/dev-e2e-tests.txt
index 30da8c5..470faf4 100644
--- a/Documentation/dev-e2e-tests.txt
+++ b/Documentation/dev-e2e-tests.txt
@@ -107,16 +107,16 @@
 file contains the commands and repository used during the e2e test. That file currently looks like
 below. This scenario serves as a simple example with no actual load in it. It can be used to test
 or validate the local setup. More complex scenarios can be further developed, under the
-`com.google.gerrit.scenarios` package.
+`com.google.gerrit.scenarios` package. The uppercase keywords are discussed further below.
 
 ----
 [
   {
-    "url": "ssh://admin@localhost:29418/loadtest-repo",
+    "url": "ssh://admin@HOSTNAME:SSH_PORT/loadtest-repo",
     "cmd": "clone"
   },
   {
-    "url": "http://localhost:8080/loadtest-repo",
+    "url": "http://HOSTNAME:HTTP_PORT/loadtest-repo",
     "cmd": "clone"
   }
 ]
@@ -143,6 +143,21 @@
 Executing the `CloneUsingBothProtocols` scenario, as is, does require setting the http credentials.
 That is because of the aforementioned create/delete project (http) scenarios composed within it.
 
+=== Environment properties
+
+The `JAVA_OPTS` environment variable
+link:https://gatling.io/docs/current/cookbook/passing_parameters[can optionally be used] to define
+non-default values for keys found in scenario `json` data files. That variable can currently be set
+with either one or many of these supported properties, from the core framework:
+
+* `-Dcom.google.gerrit.scenarios.hostname=localhost`
+* `-Dcom.google.gerrit.scenarios.ssh_port=29418`
+* `-Dcom.google.gerrit.scenarios.http_port=8080`
+
+Above, the properties can be set with values matching specific deployment topologies under test.
+The example values shown above are the currently coded default ones. The framework could support
+differing or more properties over time. Plugin (non-core) scenarios may do so just as well.
+
 == How to run tests
 
 Run all tests:
diff --git a/Documentation/index.txt b/Documentation/index.txt
index d270f97..4fb977a 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -10,11 +10,13 @@
 . link:intro-quick.html[Product Overview]
 . link:intro-how-gerrit-works.html[How Gerrit Works]
 . link:intro-gerrit-walkthrough.html[Basic Gerrit Walkthrough]
-.. link:intro-gerrit-walkthrough-github.html[Basic Gerrit Walkthrough -- For GitHub Users]
-. link:dev-community.html[Gerrit Community]
-.. link:dev-contributing.html[Contributor Guide]
+. link:intro-gerrit-walkthrough-github.html[Basic Gerrit Walkthrough -- For GitHub Users]
 
-== Guides
+== Contributor Guides
+. link:dev-community.html[Gerrit Community]
+. link:dev-community.html#how-to-contribute[How to Contribute]
+
+== User Guides
 . link:intro-user.html[User Guide]
 . link:intro-project-owner.html[Project Owner Guide]
 . link:https://source.android.com/source/developing[Default Android Workflow,role=external,window=_blank] (external)
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
index 0335b2f..1125687 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
@@ -1,10 +1,10 @@
 [
   {
-    "url": "ssh://admin@localhost:29418/loadtest-repo",
+    "url": "ssh://admin@HOSTNAME:SSH_PORT/loadtest-repo",
     "cmd": "clone"
   },
   {
-    "url": "http://localhost:8080/loadtest-repo",
+    "url": "http://HOSTNAME:HTTP_PORT/loadtest-repo",
     "cmd": "clone"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
index 2e54de5..f1a38ae 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "http://localhost:8080/a/projects/loadtest-repo"
+    "url": "http://HOSTNAME:HTTP_PORT/a/projects/loadtest-repo"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
index 9312fb4..e5167b5 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "http://localhost:8080/a/projects/loadtest-repo/delete-project~delete"
+    "url": "http://HOSTNAME:HTTP_PORT/a/projects/loadtest-repo/delete-project~delete"
   }
 ]
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
index 19fbf1b..182ac48 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
@@ -21,7 +21,7 @@
 import scala.concurrent.duration._
 
 class CloneUsingBothProtocols extends GitSimulation {
-  private val data: FileBasedFeederBuilder[Any]#F = jsonFile(resource).queue
+  private val data: FileBasedFeederBuilder[Any]#F#F = jsonFile(resource).convert(url).queue
 
   private val test: ScenarioBuilder = scenario(name)
       .feed(data)
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateProject.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateProject.scala
index 58c8994..13d3519 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateProject.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateProject.scala
@@ -19,7 +19,7 @@
 import io.gatling.core.structure.ScenarioBuilder
 
 class CreateProject extends GerritSimulation {
-  private val data: FileBasedFeederBuilder[Any]#F = jsonFile(resource).queue
+  private val data: FileBasedFeederBuilder[Any]#F#F = jsonFile(resource).convert(url).queue
 
   val test: ScenarioBuilder = scenario(name)
       .feed(data)
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteProject.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteProject.scala
index 4b723cb..70b901d 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteProject.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteProject.scala
@@ -19,7 +19,7 @@
 import io.gatling.core.structure.ScenarioBuilder
 
 class DeleteProject extends GerritSimulation {
-  private val data: FileBasedFeederBuilder[Any]#F = jsonFile(resource).queue
+  private val data: FileBasedFeederBuilder[Any]#F#F = jsonFile(resource).convert(url).queue
 
   val test: ScenarioBuilder = scenario(name)
       .feed(data)
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
index b628bc7..a159977 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
@@ -23,7 +23,8 @@
 class GerritSimulation extends Simulation {
   implicit val conf: GatlingGitConfiguration = GatlingGitConfiguration()
 
-  private val path: String = this.getClass.getPackage.getName.replaceAllLiterally(".", "/")
+  private val pack: String = this.getClass.getPackage.getName
+  private val path: String = pack.replaceAllLiterally(".", "/")
   protected val name: String = this.getClass.getSimpleName
   protected val resource: String = s"data/$path/$name.json"
 
@@ -31,4 +32,27 @@
   protected val httpProtocol: HttpProtocolBuilder = http.basicAuth(
     conf.httpConfiguration.userName,
     conf.httpConfiguration.password)
+
+  protected val url: PartialFunction[(String, Any), Any] = {
+    case ("url", url) =>
+      var in = replaceProperty("hostname", "localhost", url.toString)
+      in = replaceProperty("http_port", 8080, in)
+      replaceProperty("ssh_port", 29418, in)
+  }
+
+  private def replaceProperty(term: String, default: Any, in: String): String = {
+    val key: String = term.toUpperCase
+    val property = pack + "." + term
+    var value = default
+    default match {
+      case _: String =>
+        val propertyValue = Option(System.getProperty(property))
+        if (propertyValue.nonEmpty) {
+          value = propertyValue.get
+        }
+      case _: Integer =>
+        value = Integer.getInteger(property, default.asInstanceOf[Integer])
+    }
+    in.replaceAllLiterally(key, value.toString)
+  }
 }
diff --git a/java/com/google/gerrit/acceptance/RestSession.java b/java/com/google/gerrit/acceptance/RestSession.java
index 0e7ad4b..7ee1b26 100644
--- a/java/com/google/gerrit/acceptance/RestSession.java
+++ b/java/com/google/gerrit/acceptance/RestSession.java
@@ -14,13 +14,14 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.net.HttpHeaders.ACCEPT;
+import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
+import static com.google.gerrit.json.OutputFormat.JSON_COMPACT;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.net.HttpHeaders;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RawInput;
-import com.google.gerrit.json.OutputFormat;
 import java.io.IOException;
 import org.apache.http.Header;
 import org.apache.http.client.fluent.Request;
@@ -40,7 +41,7 @@
   }
 
   public RestResponse getJsonAccept(String endPoint) throws IOException {
-    return getWithHeader(endPoint, new BasicHeader(HttpHeaders.ACCEPT, "application/json"));
+    return getWithHeader(endPoint, new BasicHeader(ACCEPT, "application/json"));
   }
 
   public RestResponse getWithHeader(String endPoint, Header header) throws IOException {
@@ -74,8 +75,7 @@
       put.addHeader(header);
     }
     if (content != null) {
-      put.addHeader(new BasicHeader("Content-Type", "application/json"));
-      put.body(new StringEntity(OutputFormat.JSON_COMPACT.newGson().toJson(content), UTF_8));
+      addContentToRequest(put, content);
     }
     return execute(put);
   }
@@ -83,7 +83,7 @@
   public RestResponse putRaw(String endPoint, RawInput stream) throws IOException {
     requireNonNull(stream);
     Request put = Request.Put(getUrl(endPoint));
-    put.addHeader(new BasicHeader("Content-Type", stream.getContentType()));
+    put.addHeader(new BasicHeader(CONTENT_TYPE, stream.getContentType()));
     put.body(
         new BufferedHttpEntity(
             new InputStreamEntity(stream.getInputStream(), stream.getContentLength())));
@@ -105,12 +105,16 @@
       post.addHeader(header);
     }
     if (content != null) {
-      post.addHeader(new BasicHeader("Content-Type", "application/json"));
-      post.body(new StringEntity(OutputFormat.JSON_COMPACT.newGson().toJson(content), UTF_8));
+      addContentToRequest(post, content);
     }
     return execute(post);
   }
 
+  private static void addContentToRequest(Request request, Object content) {
+    request.addHeader(new BasicHeader(CONTENT_TYPE, "application/json"));
+    request.body(new StringEntity(JSON_COMPACT.newGson().toJson(content), UTF_8));
+  }
+
   public RestResponse delete(String endPoint) throws IOException {
     return execute(Request.Delete(getUrl(endPoint)));
   }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index d3fa8da..706e796 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -409,7 +409,7 @@
     if (fields.contains(ChangeField.ATTENTION_SET_FULL.getName())) {
       ChangeField.parseAttentionSet(
           FluentIterable.from(source.getAsJsonArray(ChangeField.ATTENTION_SET_FULL.getName()))
-              .transform(JsonElement::getAsString)
+              .transform(ElasticChangeIndex::decodeBase64JsonElement)
               .toSet(),
           cd);
     }
@@ -432,12 +432,16 @@
     }
     ChangeField.parseSubmitRecords(
         FluentIterable.from(records)
-            .transform(i -> new String(decodeBase64(i.getAsString()), UTF_8))
+            .transform(ElasticChangeIndex::decodeBase64JsonElement)
             .toList(),
         opts,
         out);
   }
 
+  private static String decodeBase64JsonElement(JsonElement input) {
+    return new String(decodeBase64(input.getAsString()), UTF_8);
+  }
+
   private void decodeUnresolvedCommentCount(JsonObject doc, String fieldName, ChangeData out) {
     JsonElement count = doc.get(fieldName);
     if (count == null) {
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 5087d0a..75a17a5 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.template.soy.data.ordainers.GsonOrdainer.serializeObject;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
@@ -37,8 +38,9 @@
 import com.google.template.soy.data.SanitizedContent;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import java.util.function.Function;
@@ -160,6 +162,18 @@
     return data.build();
   }
 
+  /** Returns experimentData to be used in {@code index.html}. */
+  public static Set<String> experimentData(Map<String, String[]> urlParameterMap) {
+    // Allow enable experiments with url
+    // ?experiment=a&experiment=b should result in:
+    // "experiment" => [a,b]
+    if (urlParameterMap.containsKey("experiment")) {
+      return Arrays.asList(urlParameterMap.get("experiment")).stream().collect(toSet());
+    }
+
+    return Collections.emptySet();
+  }
+
   /** Returns all static parameters of {@code index.html}. */
   static Map<String, Object> staticTemplateData(
       String canonicalURL,
@@ -217,24 +231,6 @@
     return data.build();
   }
 
-  /** Returns experimentData to be used in {@code index.html}. */
-  static Set<String> experimentData(Map<String, String[]> urlParameterMap)
-      throws URISyntaxException {
-    Set<String> enabledExperiments = new HashSet<>();
-
-    // Allow enable experiments with url
-    // ?experiment=a&experiment=b should result in:
-    // "experiment" => [a,b]
-    if (urlParameterMap.containsKey("experiment")) {
-      String[] experiments = urlParameterMap.get("experiment");
-      for (String exp : experiments) {
-        enabledExperiments.add(exp);
-      }
-    }
-
-    return enabledExperiments;
-  }
-
   private static String computeCanonicalPath(@Nullable String canonicalURL)
       throws URISyntaxException {
     if (Strings.isNullOrEmpty(canonicalURL)) {
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 4fe9daa..345da81 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -137,7 +137,9 @@
     try {
       Optional<ExternalId> optionalExtId = externalIds.get(who.getExternalIdKey());
       if (!optionalExtId.isPresent()) {
-        // New account, automatically create and return.
+        logger.atFine().log(
+            "External ID for account %s not found. A new account will be automatically created.",
+            who.getUserName());
         return create(who);
       }
 
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index c12f3a7..60a1e3e 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -148,8 +148,9 @@
   }
 
   /**
-   * @see GroupConfig#loadForGroup(Project.NameKey, Repository, AccountGroup.UUID). This method will
-   *     load the group for a specific revision.
+   * Load the group for a specific revision.
+   *
+   * @see GroupConfig#loadForGroup(Project.NameKey, Repository, AccountGroup.UUID)
    */
   public static GroupConfig loadForGroup(
       Project.NameKey projectName,
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index c6eb398..2be1324 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -31,7 +31,6 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Enums;
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -47,7 +46,6 @@
 import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
-import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -297,7 +295,7 @@
     final long timestampMillis;
     final int userId;
     final String reason;
-    final Operation operation;
+    final AttentionSetUpdate.Operation operation;
 
     StoredAttentionSetEntry(AttentionSetUpdate attentionSetUpdate) {
       timestampMillis = attentionSetUpdate.timestamp().toEpochMilli();
@@ -423,8 +421,7 @@
         continue;
       }
 
-      com.google.common.base.Optional<ReviewerStateInternal> reviewerState =
-          Enums.getIfPresent(ReviewerStateInternal.class, v.substring(0, i));
+      Optional<ReviewerStateInternal> reviewerState = getReviewerState(v.substring(0, i));
       if (!reviewerState.isPresent()) {
         logger.atWarning().log(
             "Failed to parse reviewer state of reviewer field from change %s: %s",
@@ -475,8 +472,7 @@
         continue;
       }
 
-      com.google.common.base.Optional<ReviewerStateInternal> reviewerState =
-          Enums.getIfPresent(ReviewerStateInternal.class, v.substring(0, i));
+      Optional<ReviewerStateInternal> reviewerState = getReviewerState(v.substring(0, i));
       if (!reviewerState.isPresent()) {
         logger.atWarning().log(
             "Failed to parse reviewer state of reviewer by email field from change %s: %s",
@@ -506,6 +502,14 @@
     return ReviewerByEmailSet.fromTable(b.build());
   }
 
+  private static Optional<ReviewerStateInternal> getReviewerState(String value) {
+    try {
+      return Optional.of(ReviewerStateInternal.valueOf(value));
+    } catch (IllegalArgumentException | NullPointerException e) {
+      return Optional.empty();
+    }
+  }
+
   private static ImmutableSet<Integer> getAttentionSetUserIds(ChangeData changeData) {
     return additionsOnly(changeData.attentionSet()).stream()
         .map(update -> update.account().get())
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index 743f770..68e39e7 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -123,7 +123,7 @@
     }
 
     @Override
-    public Response<?> apply(ChangeResource resource, IdString id, FileContentInput input)
+    public Response<Object> apply(ChangeResource resource, IdString id, FileContentInput input)
         throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
       putEdit.apply(resource, id.get(), input);
@@ -142,7 +142,7 @@
     }
 
     @Override
-    public Response<?> apply(ChangeResource rsrc, IdString id, Input in)
+    public Response<Object> apply(ChangeResource rsrc, IdString id, Input in)
         throws IOException, AuthException, BadRequestException, ResourceConflictException,
             PermissionBackendException {
       return deleteContent.apply(rsrc, id.get());
@@ -245,7 +245,7 @@
     }
 
     @Override
-    public Response<?> apply(ChangeResource resource, Post.Input input)
+    public Response<Object> apply(ChangeResource resource, Post.Input input)
         throws AuthException, BadRequestException, IOException, ResourceConflictException,
             PermissionBackendException {
       Project.NameKey project = resource.getProject();
@@ -292,13 +292,13 @@
     }
 
     @Override
-    public Response<?> apply(ChangeEditResource rsrc, FileContentInput input)
+    public Response<Object> apply(ChangeEditResource rsrc, FileContentInput input)
         throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
       return apply(rsrc.getChangeResource(), rsrc.getPath(), input);
     }
 
-    public Response<?> apply(ChangeResource rsrc, String path, FileContentInput input)
+    public Response<Object> apply(ChangeResource rsrc, String path, FileContentInput input)
         throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
       if (input.content == null) {
@@ -343,13 +343,13 @@
     }
 
     @Override
-    public Response<?> apply(ChangeEditResource rsrc, Input input)
+    public Response<Object> apply(ChangeEditResource rsrc, Input input)
         throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
       return apply(rsrc.getChangeResource(), rsrc.getPath());
     }
 
-    public Response<?> apply(ChangeResource rsrc, String filePath)
+    public Response<Object> apply(ChangeResource rsrc, String filePath)
         throws AuthException, BadRequestException, IOException, ResourceConflictException,
             PermissionBackendException {
       try (Repository repository = repositoryManager.openRepository(rsrc.getProject())) {
diff --git a/java/com/google/gerrit/truth/MapSubject.java b/java/com/google/gerrit/truth/MapSubject.java
new file mode 100644
index 0000000..8217920
--- /dev/null
+++ b/java/com/google/gerrit/truth/MapSubject.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * 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.gerrit.truth;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IterableSubject;
+import com.google.common.truth.Subject;
+import java.util.Map;
+
+/**
+ * A Truth subject for maps providing additional methods simplifying tests but missing on Truth's
+ * {@link com.google.common.truth.MapSubject}.
+ */
+public class MapSubject extends com.google.common.truth.MapSubject {
+
+  private final Map<?, ?> map;
+
+  public static MapSubject assertThatMap(Map<?, ?> map) {
+    return assertAbout(mapEntries()).that(map);
+  }
+
+  public static Subject.Factory<MapSubject, Map<?, ?>> mapEntries() {
+    return MapSubject::new;
+  }
+
+  private MapSubject(FailureMetadata failureMetadata, Map<?, ?> map) {
+    super(failureMetadata, map);
+    this.map = map;
+  }
+
+  public IterableSubject keys() {
+    isNotNull();
+    return check("keys()").that(map.keySet());
+  }
+
+  public IterableSubject values() {
+    isNotNull();
+    return check("values()").that(map.values());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index f30926b..dcf2afd 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -28,6 +28,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.MapSubject.assertThatMap;
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 import static java.util.stream.Collectors.toList;
@@ -217,11 +218,10 @@
     gApi.groups().id(group2.get()).addGroups("ldap:external_g2");
 
     assertThat(groupIncludeCache.allExternalMembers())
-        .containsExactlyElementsIn(
+        .containsAtLeastElementsIn(
             ImmutableList.of(
                 AccountGroup.UUID.parse("ldap:external_g1"),
-                AccountGroup.UUID.parse("ldap:external_g2"),
-                AccountGroup.UUID.parse("global:Registered-Users")));
+                AccountGroup.UUID.parse("ldap:external_g2")));
 
     assertThat(groupIncludeCache.parentGroupsOf(AccountGroup.UUID.parse("ldap:external_g1")))
         .containsExactly(group1);
@@ -247,18 +247,14 @@
 
     /** GroupIncludeCache should return ldap:external_g2 only */
     assertThat(groupIncludeCache.allExternalMembers())
-        .containsExactlyElementsIn(
-            ImmutableList.of(
-                AccountGroup.UUID.parse("ldap:external_g2"),
-                AccountGroup.UUID.parse("global:Registered-Users")));
+        .contains(AccountGroup.UUID.parse("ldap:external_g2"));
 
     /** Testing groups.getExternalGroups() with the old Snapshot */
     assertThat(groups.getExternalGroups(snapshot.groupsRefs()))
-        .containsExactlyElementsIn(
+        .containsAtLeastElementsIn(
             ImmutableList.of(
                 AccountGroup.UUID.parse("ldap:external_g1"),
-                AccountGroup.UUID.parse("ldap:external_g2"),
-                AccountGroup.UUID.parse("global:Registered-Users")));
+                AccountGroup.UUID.parse("ldap:external_g2")));
   }
 
   private ObjectId getObjectIdFromSnapshot(GroupsSnapshotReader.Snapshot snapshot, String refName) {
@@ -928,7 +924,8 @@
     List<String> expectedGroups =
         groups.getAllGroupReferences().map(GroupReference::getName).sorted().collect(toList());
     assertThat(expectedGroups.size()).isAtLeast(2);
-    assertThat(gApi.groups().list().getAsMap().keySet())
+    assertThatMap(gApi.groups().list().getAsMap())
+        .keys()
         .containsExactlyElementsIn(expectedGroups)
         .inOrder();
   }
@@ -975,20 +972,19 @@
     gApi.groups().create(in);
 
     requestScopeOperations.setApiUser(user.id());
-    assertThat(gApi.groups().list().getAsMap()).doesNotContainKey(newGroupName);
+    assertThatMap(gApi.groups().list().getAsMap()).keys().doesNotContain(newGroupName);
 
     requestScopeOperations.setApiUser(admin.id());
     gApi.groups().id(newGroupName).addMembers(user.username());
 
     requestScopeOperations.setApiUser(user.id());
-    assertThat(gApi.groups().list().getAsMap()).containsKey(newGroupName);
+    assertThatMap(gApi.groups().list().getAsMap()).keys().contains(newGroupName);
   }
 
   @Test
   public void suggestGroup() throws Exception {
     Map<String, GroupInfo> groups = gApi.groups().list().withSuggest("adm").getAsMap();
-    assertThat(groups).containsKey("Administrators");
-    assertThat(groups).hasSize(1);
+    assertThatMap(groups).keys().containsExactly("Administrators");
     assertBadRequest(gApi.groups().list().withSuggest("adm").withSubstring("foo"));
     assertBadRequest(gApi.groups().list().withSuggest("adm").withRegex("foo.*"));
     assertBadRequest(gApi.groups().list().withSuggest("adm").withUser("user"));
@@ -1005,19 +1001,15 @@
     // Choose a substring which isn't part of any group or test method within this class.
     String substring = "efghijk";
     Map<String, GroupInfo> groups = gApi.groups().list().withSubstring(substring).getAsMap();
-    assertThat(groups).containsKey(group);
-    assertThat(groups).hasSize(1);
+    assertThatMap(groups).keys().containsExactly(group);
 
     groups = gApi.groups().list().withSubstring("abcdefghi").getAsMap();
-    assertThat(groups).containsKey(group);
-    assertThat(groups).hasSize(1);
+    assertThatMap(groups).keys().containsExactly(group);
 
     String otherGroup = name("Abcdefghijklmnop2");
     gApi.groups().create(otherGroup);
     groups = gApi.groups().list().withSubstring(substring).getAsMap();
-    assertThat(groups).hasSize(2);
-    assertThat(groups).containsKey(group);
-    assertThat(groups).containsKey(otherGroup);
+    assertThatMap(groups).keys().containsExactly(group, otherGroup);
 
     groups = gApi.groups().list().withSubstring("non-existing-substring").getAsMap();
     assertThat(groups).isEmpty();
@@ -1026,15 +1018,13 @@
   @Test
   public void withRegex() throws Exception {
     Map<String, GroupInfo> groups = gApi.groups().list().withRegex("Admin.*").getAsMap();
-    assertThat(groups).containsKey("Administrators");
-    assertThat(groups).hasSize(1);
+    assertThatMap(groups).keys().containsExactly("Administrators");
 
     groups = gApi.groups().list().withRegex("admin.*").getAsMap();
     assertThat(groups).isEmpty();
 
     groups = gApi.groups().list().withRegex(".*istrators").getAsMap();
-    assertThat(groups).containsKey("Administrators");
-    assertThat(groups).hasSize(1);
+    assertThatMap(groups).keys().containsExactly("Administrators");
 
     assertBadRequest(gApi.groups().list().withRegex(".*istrators").withSubstring("s"));
   }
@@ -1043,8 +1033,7 @@
   public void allGroupInfoFieldsSetCorrectly() throws Exception {
     InternalGroup adminGroup = adminGroup();
     Map<String, GroupInfo> groups = gApi.groups().list().addGroup(adminGroup.getName()).getAsMap();
-    assertThat(groups).hasSize(1);
-    assertThat(groups).containsKey("Administrators");
+    assertThatMap(groups).keys().containsExactly("Administrators");
     assertGroupInfo(adminGroup, Iterables.getOnlyElement(groups.values()));
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index 911a04d..c6a2819 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.truth.MapSubject.assertThatMap;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
@@ -89,21 +90,16 @@
     gApi.changes().id(changeId2).current().review(ReviewInput.approve());
     gApi.changes().id(changeId2).current().submit();
     Map<String, ActionInfo> actions1 = getChangeActions(changeId1);
-    assertThat(actions1).containsKey("revert");
-    assertThat(actions1).containsKey("revert_submission");
+    assertThatMap(actions1).keys().containsAtLeast("revert", "revert_submission");
     Map<String, ActionInfo> actions2 = getChangeActions(changeId2);
-    assertThat(actions2).containsKey("revert");
-    assertThat(actions2).containsKey("revert_submission");
+    assertThatMap(actions2).keys().containsAtLeast("revert", "revert_submission");
   }
 
   @Test
   public void revisionActionsOneChangePerTopicUnapproved() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
     Map<String, ActionInfo> actions = getActions(changeId);
-    assertThat(actions).hasSize(3);
-    assertThat(actions).containsKey("cherrypick");
-    assertThat(actions).containsKey("rebase");
-    assertThat(actions).containsKey("description");
+    assertThatMap(actions).keys().containsExactly("cherrypick", "rebase", "description");
   }
 
   @Test
@@ -507,11 +503,7 @@
   }
 
   private void commonActionsAssertions(Map<String, ActionInfo> actions) {
-    assertThat(actions).hasSize(4);
-    assertThat(actions).containsKey("cherrypick");
-    assertThat(actions).containsKey("submit");
-    assertThat(actions).containsKey("description");
-    assertThat(actions).containsKey("rebase");
+    assertThatMap(actions).keys().containsExactly("cherrypick", "submit", "description", "rebase");
   }
 
   private PushOneCommit.Result createChangeWithTopic() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 2a8cca0..a30c1c7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.ConfigSubject.assertThat;
+import static com.google.gerrit.truth.MapSubject.assertThatMap;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
@@ -430,7 +431,8 @@
 
     accessInput.add.put(REFS_ALL, accessSection);
     ProjectAccessInfo result = pApi().access(accessInput);
-    assertThat(result.groups.keySet())
+    assertThatMap(result.groups)
+        .keys()
         .containsExactly(
             SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
 
@@ -443,7 +445,8 @@
 
     // Get call returns groups too.
     ProjectAccessInfo loggedInResult = pApi().access();
-    assertThat(loggedInResult.groups.keySet())
+    assertThatMap(loggedInResult.groups)
+        .keys()
         .containsExactly(
             SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
 
@@ -456,7 +459,8 @@
     // PROJECT_OWNERS is invisible to anonymous user, but GetAccess disregards visibility.
     requestScopeOperations.setApiUserAnonymous();
     ProjectAccessInfo anonResult = pApi().access();
-    assertThat(anonResult.groups.keySet())
+    assertThatMap(anonResult.groups)
+        .keys()
         .containsExactly(
             SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
   }
@@ -510,12 +514,8 @@
 
     ProjectAccessInfo updatedAccessSectionInfo =
         gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThat(
-            updatedAccessSectionInfo
-                .local
-                .get(AccessSection.GLOBAL_CAPABILITIES)
-                .permissions
-                .keySet())
+    assertThatMap(updatedAccessSectionInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+        .keys()
         .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
   }
 
@@ -544,12 +544,9 @@
 
       ProjectAccessInfo updatedAccessSectionInfo =
           gApi.projects().name(allProjects.get()).access(accessInput);
-      assertThat(
-              updatedAccessSectionInfo
-                  .local
-                  .get(AccessSection.GLOBAL_CAPABILITIES)
-                  .permissions
-                  .keySet())
+      assertThatMap(
+              updatedAccessSectionInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+          .keys()
           .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
     }
   }
@@ -644,12 +641,8 @@
 
     ProjectAccessInfo updatedProjectAccessInfo =
         gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThat(
-            updatedProjectAccessInfo
-                .local
-                .get(AccessSection.GLOBAL_CAPABILITIES)
-                .permissions
-                .keySet())
+    assertThatMap(updatedProjectAccessInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+        .keys()
         .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
 
     // Remove
@@ -657,12 +650,8 @@
     accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
 
     updatedProjectAccessInfo = gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThat(
-            updatedProjectAccessInfo
-                .local
-                .get(AccessSection.GLOBAL_CAPABILITIES)
-                .permissions
-                .keySet())
+    assertThatMap(updatedProjectAccessInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+        .keys()
         .containsNoneIn(accessSectionInfo.permissions.keySet());
   }
 
@@ -754,14 +743,12 @@
 
     // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
     Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
-    assertThat(local).isNotNull();
-    assertThat(local).containsKey(RefNames.REFS_GROUPS + "*");
+    assertThatMap(local).keys().contains(RefNames.REFS_GROUPS + "*");
     Map<String, PermissionInfo> permissions = local.get(RefNames.REFS_GROUPS + "*").permissions;
-    assertThat(permissions).hasSize(2);
     // READ is the default permission and should be preserved by the syncer
-    assertThat(permissions.keySet()).containsExactly(Permission.READ, Permission.CREATE);
+    assertThatMap(permissions).keys().containsExactly(Permission.READ, Permission.CREATE);
     Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
-    assertThat(rules.values()).containsExactly(pri);
+    assertThatMap(rules).values().containsExactly(pri);
 
     // Revoke the permission
     accessInput.add.clear();
@@ -770,12 +757,10 @@
 
     // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
     Map<String, AccessSectionInfo> local2 = gApi.projects().name("All-Users").access().local;
-    assertThat(local2).isNotNull();
-    assertThat(local2).containsKey(RefNames.REFS_GROUPS + "*");
+    assertThatMap(local2).keys().contains(RefNames.REFS_GROUPS + "*");
     Map<String, PermissionInfo> permissions2 = local2.get(RefNames.REFS_GROUPS + "*").permissions;
-    assertThat(permissions2).hasSize(1);
     // READ is the default permission and should be preserved by the syncer
-    assertThat(permissions2.keySet()).containsExactly(Permission.READ);
+    assertThatMap(permissions2).keys().containsExactly(Permission.READ);
   }
 
   @Test
@@ -803,14 +788,13 @@
 
     // Assert that the permissions were synced from All-Projects (global) to All-Users (ref)
     Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
-    assertThat(local).isNotNull();
-    assertThat(local).containsKey(RefNames.REFS_GROUPS + "*");
+    assertThatMap(local).keys().contains(RefNames.REFS_GROUPS + "*");
     Map<String, PermissionInfo> permissions = local.get(RefNames.REFS_GROUPS + "*").permissions;
-    assertThat(permissions).hasSize(2);
     // READ is the default permission and should be preserved by the syncer
-    assertThat(permissions.keySet()).containsExactly(Permission.READ, Permission.CREATE);
+    assertThatMap(permissions).keys().containsExactly(Permission.READ, Permission.CREATE);
     Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
-    assertThat(rules.keySet())
+    assertThatMap(rules)
+        .keys()
         .containsExactly(SystemGroupBackend.REGISTERED_USERS.get(), adminGroupUuid().get());
     assertThat(rules.get(SystemGroupBackend.REGISTERED_USERS.get())).isEqualTo(pri);
     assertThat(rules.get(adminGroupUuid().get())).isEqualTo(pri);
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
index 6802873..2ae59b0 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
@@ -23,17 +23,12 @@
 import org.eclipse.jgit.lib.Config;
 
 public final class ElasticTestUtils {
-  public static class ElasticNodeInfo {
-    public final int port;
-
-    public ElasticNodeInfo(int port) {
-      this.port = port;
-    }
-  }
-
-  public static void configure(Config config, int port, String prefix, ElasticVersion version) {
+  public static void configure(
+      Config config, ElasticContainer container, String prefix, ElasticVersion version) {
+    String hostname = container.getHttpHost().getHostName();
+    int port = container.getHttpHost().getPort();
     config.setString("index", null, "type", "elasticsearch");
-    config.setString("elasticsearch", null, "server", "http://localhost:" + port);
+    config.setString("elasticsearch", null, "server", "http://" + hostname + ":" + port);
     config.setString("elasticsearch", null, "prefix", prefix);
     config.setInt("index", null, "maxLimit", 10000);
     String password = version == ElasticVersion.V5_6 ? "changeme" : null;
@@ -42,8 +37,8 @@
     }
   }
 
-  public static void configure(Config config, int port, String prefix) {
-    configure(config, port, prefix, null);
+  public static void configure(Config config, ElasticContainer container, String prefix) {
+    configure(config, container, prefix, null);
   }
 
   public static void createAllIndexes(Injector injector) {
@@ -55,12 +50,10 @@
   }
 
   public static Config getConfig(ElasticVersion version) {
-    ElasticNodeInfo elasticNodeInfo;
     ElasticContainer container = ElasticContainer.createAndStart(version);
-    elasticNodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
     String indicesPrefix = UUID.randomUUID().toString();
     Config cfg = new Config();
-    configure(cfg, elasticNodeInfo.port, indicesPrefix, version);
+    configure(cfg, container, indicesPrefix, version);
     return cfg;
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
index e5bd19f..ab2b98d 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -31,18 +30,14 @@
     return IndexConfig.createForElasticsearch();
   }
 
-  private static ElasticNodeInfo nodeInfo;
   private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
+    if (container == null) {
+      // Only start Elasticsearch once
+      container = ElasticContainer.createAndStart(ElasticVersion.V5_6);
     }
-
-    container = ElasticContainer.createAndStart(ElasticVersion.V5_6);
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
   @AfterClass
@@ -63,8 +58,7 @@
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(
-        elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
+    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix, ElasticVersion.V5_6);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
index e1aadb8..97f235c 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.GerritTestName;
@@ -33,18 +32,14 @@
     return IndexConfig.createForElasticsearch();
   }
 
-  private static ElasticNodeInfo nodeInfo;
   private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
+    if (container == null) {
+      // Only start Elasticsearch once
+      container = ElasticContainer.createAndStart(ElasticVersion.V5_6);
     }
-
-    container = ElasticContainer.createAndStart(ElasticVersion.V5_6);
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
   @AfterClass
@@ -67,8 +62,7 @@
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(
-        elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
+    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix, ElasticVersion.V5_6);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
index fcec859..c490e78 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -31,18 +30,14 @@
     return IndexConfig.createForElasticsearch();
   }
 
-  private static ElasticNodeInfo nodeInfo;
   private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
+    if (container == null) {
+      // Only start Elasticsearch once
+      container = ElasticContainer.createAndStart(ElasticVersion.V5_6);
     }
-
-    container = ElasticContainer.createAndStart(ElasticVersion.V5_6);
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
   @AfterClass
@@ -63,8 +58,7 @@
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(
-        elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
+    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix, ElasticVersion.V5_6);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
index 16f06d5..a6a8605 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV5QueryProjectsTest.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.project.AbstractQueryProjectsTest;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -31,18 +30,14 @@
     return IndexConfig.createForElasticsearch();
   }
 
-  private static ElasticNodeInfo nodeInfo;
   private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
+    if (container == null) {
+      // Only start Elasticsearch once
+      container = ElasticContainer.createAndStart(ElasticVersion.V5_6);
     }
-
-    container = ElasticContainer.createAndStart(ElasticVersion.V5_6);
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
   @AfterClass
@@ -63,8 +58,7 @@
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(
-        elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
+    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix, ElasticVersion.V5_6);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
index 7a69546..15d8dd6 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -31,18 +30,14 @@
     return IndexConfig.createForElasticsearch();
   }
 
-  private static ElasticNodeInfo nodeInfo;
   private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
+    if (container == null) {
+      // Only start Elasticsearch once
+      container = ElasticContainer.createAndStart(ElasticVersion.V6_8);
     }
-
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_8);
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
   @AfterClass
@@ -63,7 +58,7 @@
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
index 1601756..d734f1e 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
@@ -16,7 +16,6 @@
 
 import static java.util.concurrent.TimeUnit.MINUTES;
 
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.GerritTestName;
@@ -40,21 +39,17 @@
     return IndexConfig.createForElasticsearch();
   }
 
-  private static ElasticNodeInfo nodeInfo;
   private static ElasticContainer container;
   private static CloseableHttpAsyncClient client;
 
   @BeforeClass
   public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
+    if (container == null) {
+      // Only start Elasticsearch once
+      container = ElasticContainer.createAndStart(ElasticVersion.V6_8);
+      client = HttpAsyncClients.createDefault();
+      client.start();
     }
-
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_8);
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
-    client = HttpAsyncClients.createDefault();
-    client.start();
   }
 
   @AfterClass
@@ -68,12 +63,16 @@
 
   @After
   public void closeIndex() throws Exception {
+    // Close the index after each test to prevent exceeding Elasticsearch's
+    // shard limit (see Issue 10120).
     client
         .execute(
             new HttpPost(
                 String.format(
-                    "http://localhost:%d/%s*/_close",
-                    nodeInfo.port, testName.getSanitizedMethodName())),
+                    "http://%s:%d/%s*/_close",
+                    container.getHttpHost().getHostName(),
+                    container.getHttpHost().getPort(),
+                    testName.getSanitizedMethodName())),
             HttpClientContext.create(),
             null)
         .get(5, MINUTES);
@@ -90,7 +89,7 @@
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
index 76a4f9b..28d798e 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -31,18 +30,14 @@
     return IndexConfig.createForElasticsearch();
   }
 
-  private static ElasticNodeInfo nodeInfo;
   private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
+    if (container == null) {
+      // Only start Elasticsearch once
+      container = ElasticContainer.createAndStart(ElasticVersion.V6_8);
     }
-
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_8);
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
   @AfterClass
@@ -63,7 +58,7 @@
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java
index 7813a02..6658d72 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryProjectsTest.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.project.AbstractQueryProjectsTest;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -31,18 +30,14 @@
     return IndexConfig.createForElasticsearch();
   }
 
-  private static ElasticNodeInfo nodeInfo;
   private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
+    if (container == null) {
+      // Only start Elasticsearch once
+      container = ElasticContainer.createAndStart(ElasticVersion.V6_8);
     }
-
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_8);
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
   @AfterClass
@@ -63,7 +58,7 @@
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
index 4bfcb6c..7636e5d 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -31,18 +30,14 @@
     return IndexConfig.createForElasticsearch();
   }
 
-  private static ElasticNodeInfo nodeInfo;
   private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
+    if (container == null) {
+      // Only start Elasticsearch once
+      container = ElasticContainer.createAndStart(ElasticVersion.V7_6);
     }
-
-    container = ElasticContainer.createAndStart(ElasticVersion.V7_6);
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
   @AfterClass
@@ -63,7 +58,7 @@
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
index d927c2d..cbb8300d 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
@@ -16,7 +16,6 @@
 
 import static java.util.concurrent.TimeUnit.MINUTES;
 
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.GerritTestName;
@@ -40,21 +39,17 @@
     return IndexConfig.createForElasticsearch();
   }
 
-  private static ElasticNodeInfo nodeInfo;
   private static ElasticContainer container;
   private static CloseableHttpAsyncClient client;
 
   @BeforeClass
   public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
+    if (container == null) {
+      // Only start Elasticsearch once
+      container = ElasticContainer.createAndStart(ElasticVersion.V7_6);
+      client = HttpAsyncClients.createDefault();
+      client.start();
     }
-
-    container = ElasticContainer.createAndStart(ElasticVersion.V7_6);
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
-    client = HttpAsyncClients.createDefault();
-    client.start();
   }
 
   @AfterClass
@@ -68,12 +63,16 @@
 
   @After
   public void closeIndex() throws Exception {
+    // Close the index after each test to prevent exceeding Elasticsearch's
+    // shard limit (see Issue 10120).
     client
         .execute(
             new HttpPost(
                 String.format(
-                    "http://localhost:%d/%s*/_close",
-                    nodeInfo.port, testName.getSanitizedMethodName())),
+                    "http://%s:%d/%s*/_close",
+                    container.getHttpHost().getHostName(),
+                    container.getHttpHost().getPort(),
+                    testName.getSanitizedMethodName())),
             HttpClientContext.create(),
             null)
         .get(5, MINUTES);
@@ -90,7 +89,7 @@
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
index 447d47a..5e3c4fb 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -31,18 +30,14 @@
     return IndexConfig.createForElasticsearch();
   }
 
-  private static ElasticNodeInfo nodeInfo;
   private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
+    if (container == null) {
+      // Only start Elasticsearch once
+      container = ElasticContainer.createAndStart(ElasticVersion.V7_6);
     }
-
-    container = ElasticContainer.createAndStart(ElasticVersion.V7_6);
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
   @AfterClass
@@ -63,7 +58,7 @@
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
index 5eebf41..803b5da 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.project.AbstractQueryProjectsTest;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -31,18 +30,14 @@
     return IndexConfig.createForElasticsearch();
   }
 
-  private static ElasticNodeInfo nodeInfo;
   private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
+    if (container == null) {
+      // Only start Elasticsearch once
+      container = ElasticContainer.createAndStart(ElasticVersion.V7_6);
     }
-
-    container = ElasticContainer.createAndStart(ElasticVersion.V7_6);
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
   @AfterClass
@@ -63,7 +58,7 @@
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
   }
 }
diff --git a/plugins/download-commands b/plugins/download-commands
index 3f5a024..e26ed31 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 3f5a024fd46f30f4646bfceb285763e44fda15a7
+Subproject commit e26ed31aaf070ff884e96b9a09d39c20437de6cb
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js
index 7e38f9b..ec51fc0 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js
@@ -170,6 +170,9 @@
         margin: var(--spacing-l) 0;
         padding: 0 var(--spacing-l);
       }
+      #startReviewBtn {
+        margin-left: var(--spacing-s);
+      }
       .collapseToggleContainer {
         display: flex;
         margin-bottom: 8px;
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
index f184b6c..37c3d52 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
@@ -326,11 +326,15 @@
    * @private
    */
   _getNextindex(delta, opt_condition, opt_clipToTop) {
-    if (!this.stops.length || this.index === -1) {
+    if (!this.stops.length) {
       return -1;
     }
-
     let newIndex = this.index;
+    // If the cursor is not yet set and we are going backwards, start at the
+    // back.
+    if (this.index === -1 && delta < 0) {
+      newIndex = this.stops.length;
+    }
     do {
       newIndex = newIndex + delta;
     } while (newIndex > 0 &&
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
index 2f56ea6..1f5b4c0 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
@@ -127,6 +127,29 @@
     assert.equal(element.index, -1);
   });
 
+  test('next() goes to first element when no cursor is set', () => {
+    element.stops = list.querySelectorAll('li');
+    element.next();
+
+    assert.equal(element.index, 0);
+    assert.equal(element.target, list.children[0]);
+    assert.isTrue(list.children[0].classList.contains('targeted'));
+    assert.isTrue(element.isAtStart());
+    assert.isFalse(element.isAtEnd());
+  });
+
+  test('next() goes to first element when no cursor is set', () => {
+    element.stops = list.querySelectorAll('li');
+    element.previous();
+
+    const lastIndex = list.children.length - 1;
+    assert.equal(element.index, lastIndex);
+    assert.equal(element.target, list.children[lastIndex]);
+    assert.isTrue(list.children[lastIndex].classList.contains('targeted'));
+    assert.isFalse(element.isAtStart());
+    assert.isTrue(element.isAtEnd());
+  });
+
   test('_moveCursor', () => {
     // Initialize the cursor with its stops.
     element.stops = list.querySelectorAll('li');
@@ -143,8 +166,8 @@
     assert.isTrue(getTargetHeight.called);
   });
 
-  test('_moveCursor from -1 does not check height', () => {
-    element.stops = list.querySelectorAll('li');
+  test('_moveCursor from for invalid index does not check height', () => {
+    element.stops = [];
     const getTargetHeight = sinon.stub();
     element._moveCursor(1, () => false, getTargetHeight);
     assert.isFalse(getTargetHeight.called);