Merge changes I1109347f,I16f79dd6

* changes:
  Fix a weird layering issue with check action tooltips
  Remove '- filtered' text from check result section title
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index b4e65b0..5889c75 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -288,13 +288,25 @@
 
 Matches votes that are equal to the minimal or maximal voting range. Or any votes.
 
-==== approverin:`groupUUID`
+==== approverin:link:rest-api-groups.html#group-id[\{group-id\}]
 
-Matches votes granted by a user who is a member of `groupUUID`.
+Matches votes granted by a user who is a member of
+link:rest-api-groups.html#group-id[\{group-id\}].
 
-==== uploaderin:`groupUUID`
+Avoid using a group name with spaces (if it has spaces, use the group uuid).
+Although supported for convenience, it's better to use group uuid than group
+name since using names only works as long as the names are unique (and future
+groups with the same name will break the query).
 
-Matches votes where the new patch set was uploaded by a member of `groupUUID`.
+==== uploaderin:link:rest-api-groups.html#group-id[\{group-id\}]
+
+Matches votes where the new patch set was uploaded by a member of
+link:rest-api-groups.html#group-id[\{group-id\}].
+
+Avoid using a group name with spaces (if it has spaces, use the group uuid).
+Although supported for convenience, it's better to use group uuid than group
+name since using names only works as long as the names are unique (and future
+groups with the same name will break the query).
 
 ==== has:unchanged-files
 
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index a01df50..4dff685 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -165,21 +165,21 @@
 a commit for review that doesn't contain a Change-Id in the commit
 message fails with link:error-missing-changeid.html[missing Change-Id
 in commit message footer].
-
++
 It is recommended to set this option and use a
 link:user-changeid.html#create[commit-msg hook] (or other client side
 tooling like EGit) to automatically generate Change-Id's for new
 commits. This way the Change-Id is automatically in place when changes
 are reworked or rebased and uploading new patch sets gets easy.
-
++
 If this option is not set, commits can be uploaded without a Change-Id,
 but then users have to remember to copy the assigned Change-Id from the
 change screen and insert it manually into the commit message when they
 want to upload a second patch set.
-
++
 Default is `INHERIT`, which means that this property is inherited from
 the parent project. The global default for new hosts is `true`
-
++
 This option is deprecated and future releases will behave as if this
 is always `true`.
 
@@ -262,18 +262,18 @@
 
 [[receive.createNewChangeForAllNotInTarget]]receive.createNewChangeForAllNotInTarget::
 +
-The `create-new-change-for-all-not-in-target` option provides a
-convenience for selecting link:user-upload.html#base[the merge base]
-by setting it automatically to the target branch's tip so you can
-create new changes for all commits not in the target branch.
-
+This option provides a convenience for selecting
+link:user-upload.html#base[the merge base] by setting it automatically
+to the target branch's tip so you can create new changes for all
+commits not in the target branch.
++
 This option is disabled if the tip of the push is a merge commit.
-
++
 This option also only works if there are no merge commits in the
 commit chain, in such cases it fails warning the user that such
 pushes can only be performed by manually specifying
 link:user-upload.html#base[bases]
-
++
 This option is useful if you want to push a change to your personal
 branch first and for review to another branch for example. Or in cases
 where a commit is already merged into a branch and you want to create
@@ -494,9 +494,9 @@
 names in this section defines the branch order. The topmost is considered to be
 the least stable branch (typically the master branch) and the last one the
 most stable (typically the last maintained release branch).
-
++
 Example:
-
++
 ----
 [branchOrder]
   branch = master
@@ -504,13 +504,13 @@
   branch = stable-2.8
   branch = stable-2.7
 ----
-
++
 The `branchOrder` section is inheritable. This is useful when multiple or all
 projects follow the same branch rules. A `branchOrder` section in a child
 project completely overrides any `branchOrder` section from a parent i.e. there
 is no merging of `branchOrder` sections. A present but empty `branchOrder`
 section removes all inherited branch order.
-
++
 Branches not listed in this section will not be included in the mergeability
 check. If the `branchOrder` section is not defined then the mergeability of a
 change into other branches will not be done.
@@ -525,9 +525,9 @@
 +
 A boolean indicating if reviewers and CCs that do not currently have a Gerrit
 account can be added to a change by providing their email address.
-
++
 This setting only takes affect for changes that are readable by anonymous users.
-
++
 Default is `INHERIT`, which means that this property is inherited from
 the parent project. If the property is not set in any parent project, the
 default value is `FALSE`.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 724436f..0c5ea40 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -6655,6 +6655,14 @@
 |`new_branch`         |optional, default to `false`|
 Allow creating a new branch when set to `true`. Using this option is
 only possible for non-merge commits (if the `merge` field is not set).
+|`validation_options` |optional|
+Map with key-value pairs that are forwarded as options to the commit validation
+listeners (e.g. can be used to skip certain validations). Which validation
+options are supported depends on the installed commit validation listeners.
+Gerrit core doesn't support any validation options, but commit validation
+listeners that are implemented in plugins may. Please refer to the
+documentation of the installed plugins to learn whether they support validation
+options. Unknown validation options are silently ignored.
 |`merge`              |optional|
 The detail of a merge commit as a link:#merge-input[MergeInput] entity.
 If set, the target branch (see  `branch` field) must exist (it is not
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 5ee3136..128bae6 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -10,7 +10,7 @@
 == Recipient Type
 
 Those are the available recipient types:
-+
+
 * `to`: The standard To field is used; addresses are visible to all.
 * `cc`: The standard CC field is used; addresses are visible to all.
 * `bcc`: SMTP RCPT TO is used to hide the address.
diff --git a/java/com/google/gerrit/extensions/common/ChangeInput.java b/java/com/google/gerrit/extensions/common/ChangeInput.java
index 1949ff4..ea12ef1 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInput.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInput.java
@@ -33,6 +33,7 @@
   public String baseChange;
   public String baseCommit;
   public Boolean newBranch;
+  public Map<String, String> validationOptions;
   public MergeInput merge;
 
   public AccountInput author;
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index 7bbe70b..b255833 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -19,7 +19,6 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
 import com.google.common.primitives.Ints;
 import com.google.common.primitives.Longs;
 import com.google.gerrit.index.FieldDef;
@@ -36,7 +35,7 @@
    * complexity was reduced to the bare minimum at the cost of small discrepancies to the Unicode
    * spec.
    */
-  private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-\\/_\n"));
+  private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-:\\/_\n"));
 
   private final FieldDef<I, ?> def;
 
@@ -106,7 +105,7 @@
     } else if (fieldTypeName.equals(FieldType.FULL_TEXT.getName())) {
       Set<String> tokenizedField = tokenizeString(String.valueOf(fieldValueFromObject));
       Set<String> tokenizedValue = tokenizeString(value);
-      return !Sets.intersection(tokenizedField, tokenizedValue).isEmpty();
+      return !tokenizedValue.isEmpty() && tokenizedField.containsAll(tokenizedValue);
     } else if (fieldTypeName.equals(FieldType.STORED_ONLY.getName())) {
       throw new IllegalStateException("can't filter for storedOnly field " + getField().getName());
     } else if (fieldTypeName.equals(FieldType.TIMESTAMP.getName())) {
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 6728ba2..85482e4 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -131,6 +131,7 @@
   private boolean isPrivate;
   private boolean workInProgress;
   private List<String> groups = Collections.emptyList();
+  private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
   private boolean validate = true;
   private Map<String, Short> approvals;
   private RequestScopePropagator requestScopePropagator;
@@ -305,11 +306,21 @@
 
   public ChangeInserter setGroups(List<String> groups) {
     requireNonNull(groups, "groups may not be empty");
-    checkState(patchSet == null, "setGroups(Iterable<String>) only valid before creating change");
+    checkState(patchSet == null, "setGroups(List<String>) only valid before creating change");
     this.groups = groups;
     return this;
   }
 
+  public ChangeInserter setValidationOptions(
+      ImmutableListMultimap<String, String> validationOptions) {
+    checkState(
+        patchSet == null,
+        "setValidationOptions(ImmutableListMultimap<String, String>) only valid before creating a"
+            + " change");
+    this.validationOptions = validationOptions;
+    return this;
+  }
+
   public ChangeInserter setFireRevisionCreated(boolean fireRevisionCreated) {
     this.fireRevisionCreated = fireRevisionCreated;
     return this;
@@ -563,7 +574,7 @@
               cmd,
               projectState.getProject(),
               change.getDest().branch(),
-              ImmutableListMultimap.of(),
+              validationOptions,
               ctx.getRepoView().getConfig(),
               ctx.getRevWalk().getObjectReader(),
               commitId,
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
index f412cc7..819f319 100644
--- a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
@@ -85,13 +85,12 @@
       throws QueryParseException {
     try {
       return Enum.valueOf(clazz, term.toUpperCase().replace('-', '_'));
-    } catch (
-        @SuppressWarnings("UnusedException")
-        IllegalArgumentException unused) {
+    } catch (IllegalArgumentException e) {
       throw new QueryParseException(
           String.format(
               "%s is not a valid term. valid options: %s",
-              term, Arrays.asList(clazz.getEnumConstants())));
+              term, Arrays.asList(clazz.getEnumConstants())),
+          e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index f48c7b8..fa47bef 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -19,6 +19,7 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -387,6 +388,17 @@
       ins.setPrivate(input.isPrivate);
       ins.setWorkInProgress(input.workInProgress || !c.getFilesWithGitConflicts().isEmpty());
       ins.setGroups(groups);
+
+      if (input.validationOptions != null) {
+        ImmutableListMultimap.Builder<String, String> validationOptions =
+            ImmutableListMultimap.builder();
+        input
+            .validationOptions
+            .entrySet()
+            .forEach(e -> validationOptions.put(e.getKey(), e.getValue()));
+        ins.setValidationOptions(validationOptions.build());
+      }
+
       try (BatchUpdate bu = updateFactory.create(projectState.getNameKey(), me, now)) {
         bu.setRepository(git, rw, oi);
         bu.setNotify(
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 129b546..6b6dffc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -28,9 +28,12 @@
 import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
@@ -63,6 +66,10 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.submit.ChangeAlreadyMergedException;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
@@ -88,6 +95,7 @@
 public class CreateChangeIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Test
   public void createEmptyChange_MissingBranch() throws Exception {
@@ -963,6 +971,24 @@
     assertThrows(BadRequestException.class, () -> gApi.changes().create(in));
   }
 
+  @Test
+  public void createChangeWithValidationOptions() throws Exception {
+    ChangeInput changeInput = new ChangeInput();
+    changeInput.project = project.get();
+    changeInput.branch = "master";
+    changeInput.subject = "A change";
+    changeInput.status = ChangeStatus.NEW;
+    changeInput.validationOptions = ImmutableMap.of("key", "value");
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      assertCreateSucceeds(changeInput);
+      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
   private ChangeInput newChangeInput(ChangeStatus status) {
     ChangeInput in = new ChangeInput();
     in.project = project.get();
@@ -1132,4 +1158,15 @@
 
     return ImmutableMap.of("master", initialCommit, branchA, changeA, branchB, changeB);
   }
+
+  private static class TestCommitValidationListener implements CommitValidationListener {
+    public CommitReceivedEvent receiveEvent;
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      this.receiveEvent = receiveEvent;
+      return ImmutableList.of();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 6c8026b..8505473 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -934,6 +934,21 @@
   }
 
   @Test
+  public void fullTextMultipleTerms() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("Signed-off: owner").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("Signed by owner").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    RevCommit commit3 = repo.parseBody(repo.commit().message("This change is off").create());
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+
+    assertQuery("message:\"Signed-off: owner\"", change1);
+    assertQuery("message:\"Signed\"", change2, change1);
+    assertQuery("message:\"off\"", change3, change1);
+  }
+
+  @Test
   public void byMessageMixedCase() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("Hello gerrit").create());
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 724141f..3f51cf0 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -407,7 +407,7 @@
 }
 
 /** An instance of the GrDiff Webcomponent */
-export interface GrDiff extends HTMLElement {
+export declare interface GrDiff extends HTMLElement {
   /**
    * Return line number element for reading only,
    *
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
similarity index 62%
rename from polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
rename to polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
index a00b8142..c4f1df7 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
@@ -17,12 +17,23 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-group-audit-log.js';
-import {stubRestApi, addListenerForTest, mockPromise} from '../../../test/test-utils.js';
+import {
+  stubRestApi,
+  addListenerForTest,
+  mockPromise,
+} from '../../../test/test-utils.js';
+import {GrGroupAuditLog} from './gr-group-audit-log.js';
+import {EncodedGroupId, GroupInfo, GroupName} from '../../../types/common.js';
+import {
+  createAccountWithId,
+  createGroupInfo,
+} from '../../../test/test-data-generators.js';
+import {PageErrorEvent} from '../../../types/events.js';
 
 const basicFixture = fixtureFromElement('gr-group-audit-log');
 
 suite('gr-group-audit-log tests', () => {
-  let element;
+  let element: GrGroupAuditLog;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -30,19 +41,14 @@
 
   suite('members', () => {
     test('test _getNameForGroup', () => {
-      let group = {
-        member: {
-          name: 'test-name',
-        },
+      let member: GroupInfo = {
+        ...createGroupInfo(),
+        name: 'test-name' as GroupName,
       };
-      assert.equal(element._getNameForGroup(group.member), 'test-name');
+      assert.equal(element._getNameForGroup(member), 'test-name');
 
-      group = {
-        member: {
-          id: 'test-id',
-        },
-      };
-      assert.equal(element._getNameForGroup(group.member), 'test-id');
+      member = createGroupInfo('test-id');
+      assert.equal(element._getNameForGroup(member), 'test-id');
     });
 
     test('test _isGroupEvent', () => {
@@ -56,13 +62,11 @@
 
   suite('users', () => {
     test('test _getIdForUser', () => {
-      const account = {
-        user: {
-          username: 'test-user',
-          _account_id: 12,
-        },
+      const user = {
+        ...createAccountWithId(12),
+        username: 'test-user',
       };
-      assert.equal(element._getIdForUser(account.user), ' (12)');
+      assert.equal(element._getIdForUser(user), ' (12)');
     });
 
     test('test _account_id not present', () => {
@@ -77,18 +81,18 @@
 
   suite('404', () => {
     test('fires page-error', async () => {
-      element.groupId = 1;
+      element.groupId = '1' as EncodedGroupId;
       await flush();
 
-      const response = {status: 404};
-      stubRestApi('getGroupAuditLog').callsFake((group, errFn) => {
-        errFn(response);
+      const response = {...new Response(), status: 404};
+      stubRestApi('getGroupAuditLog').callsFake((_group, errFn) => {
+        if (errFn) errFn(response);
         return Promise.resolve(undefined);
       });
 
       const pageErrorCalled = mockPromise();
       addListenerForTest(document, 'page-error', e => {
-        assert.deepEqual(e.detail.response, response);
+        assert.deepEqual((e as PageErrorEvent).detail.response, response);
         pageErrorCalled.resolve();
       });
 
@@ -97,4 +101,3 @@
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
index 68bdaa6..958f367 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -15,11 +15,7 @@
  * limitations under the License.
  */
 
-import {customElement, observe, property} from '@polymer/decorators';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {Subscription} from 'rxjs';
 import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
 import {
@@ -583,98 +579,3 @@
     return targetableStops.find(stop => stop.querySelector(selector));
   }
 }
-
-// TODO(oler): Remove this once clients have migrated to using GrDiffCursor service.
-@customElement('gr-diff-cursor')
-export class GrDiffCursorElement extends PolymerElement {
-  static get template() {
-    return html``;
-  }
-
-  @property({type: String, observer: '_sideChanged'})
-  side: Side = Side.RIGHT;
-
-  _sideChanged(side: Side) {
-    this.cursor.side = side;
-  }
-
-  @property({type: Object}) diffs: GrDiff[] = [];
-
-  @observe('diffs.splices')
-  _diffsChanged() {
-    if (this.diffs) {
-      this.cursor.replaceDiffs(this.diffs);
-    }
-  }
-
-  private cursor = new GrDiffCursor();
-
-  /** @override */
-  ready() {
-    super.ready();
-    afterNextRender(this, () => {
-      /*
-      This represents the diff cursor is ready for interaction coming from
-      client components. It is more then Polymer "ready" lifecycle, as no
-      "ready" events are automatically fired by Polymer, it means
-      the cursor is completely interactable - in this case attached and
-      painted on the page. We name it "ready" instead of "rendered" as the
-      long-term goal is to make gr-diff-cursor a javascript class - not a DOM
-      element with an actual lifecycle. This will be triggered only once
-      per element.
-      */
-      this.dispatchEvent(
-        new CustomEvent('ready', {
-          composed: true,
-          bubbles: false,
-        })
-      );
-    });
-  }
-
-  /** @override */
-  disconnectedCallback() {
-    this.cursor.dispose();
-    super.disconnectedCallback();
-  }
-
-  isAtStart = this.cursor.isAtStart.bind(this.cursor);
-
-  isAtEnd = this.cursor.isAtEnd.bind(this.cursor);
-
-  moveLeft = this.cursor.moveLeft.bind(this.cursor);
-
-  moveRight = this.cursor.moveRight.bind(this.cursor);
-
-  moveDown = this.cursor.moveDown.bind(this.cursor);
-
-  moveUp = this.cursor.moveUp.bind(this.cursor);
-
-  moveToNextChunk = this.cursor.moveToNextChunk.bind(this.cursor);
-
-  moveToPreviousChunk = this.cursor.moveToPreviousChunk.bind(this.cursor);
-
-  moveToNextCommentThread = this.cursor.moveToNextCommentThread.bind(
-    this.cursor
-  );
-
-  moveToPreviousCommentThread = this.cursor.moveToPreviousCommentThread.bind(
-    this.cursor
-  );
-
-  moveToLineNumber = this.cursor.moveToLineNumber.bind(this.cursor);
-
-  moveToFirstChunk = this.cursor.moveToFirstChunk.bind(this.cursor);
-
-  moveToLastChunk = this.cursor.moveToLastChunk.bind(this.cursor);
-
-  reInit = this.cursor.resetScrollMode.bind(this.cursor);
-
-  createCommentInPlace = this.cursor.createCommentInPlace.bind(this.cursor);
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-cursor': GrDiffCursorElement;
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
deleted file mode 100644
index 459c8c7..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
+++ /dev/null
@@ -1,119 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 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.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-account-label.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-account-label');
-
-suite('gr-account-label tests', () => {
-  let element;
-  const kermit = createAccount('kermit', 31);
-
-  function createAccount(name, id) {
-    return {name, _account_id: id};
-  }
-
-  setup(() => {
-    stubRestApi('getAccount').callsFake(() => Promise.resolve(kermit));
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    element = basicFixture.instantiate();
-    element._config = {
-      user: {
-        anonymous_coward_name: 'Anonymous Coward',
-      },
-    };
-  });
-
-  test('null guard', () => {
-    assert.doesNotThrow(() => {
-      element.account = null;
-    });
-  });
-
-  suite('_computeName', () => {
-    test('not showing anonymous', () => {
-      const account = {name: 'Wyatt'};
-      assert.deepEqual(element._computeName(account, null), 'Wyatt');
-    });
-
-    test('showing anonymous but no config', () => {
-      const account = {};
-      assert.deepEqual(element._computeName(account, null),
-          'Anonymous');
-    });
-
-    test('test for Anonymous Coward user and replace with Anonymous', () => {
-      const config = {
-        user: {
-          anonymous_coward_name: 'Anonymous Coward',
-        },
-      };
-      const account = {};
-      assert.deepEqual(element._computeName(account, config),
-          'Anonymous');
-    });
-
-    test('test for anonymous_coward_name', () => {
-      const config = {
-        user: {
-          anonymous_coward_name: 'TestAnon',
-        },
-      };
-      const account = {};
-      assert.deepEqual(element._computeName(account, config),
-          'TestAnon');
-    });
-  });
-
-  suite('attention set', () => {
-    setup(async () => {
-      element.highlightAttention = true;
-      element._config = {
-        user: {anonymous_coward_name: 'Anonymous Coward'},
-      };
-      element._selfAccount = kermit;
-      element.account = createAccount('ernie', 42);
-      element.change = {
-        attention_set: {42: {}},
-        owner: kermit,
-        reviewers: {},
-      };
-      await flush();
-    });
-
-    test('show attention button', () => {
-      const button = element.shadowRoot.querySelector('#attentionButton');
-      assert.ok(button);
-      assert.isNull(button.getAttribute('disabled'));
-    });
-
-    test('tap attention button', async () => {
-      const apiStub = stubRestApi(
-          'removeFromAttentionSet')
-          .callsFake(() => Promise.resolve());
-      const button = element.shadowRoot.querySelector('#attentionButton');
-      assert.ok(button);
-      assert.isNull(button.getAttribute('disabled'));
-      MockInteractions.tap(button);
-      assert.isTrue(apiStub.calledOnce);
-      assert.equal(apiStub.lastCall.args[1], 42);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
new file mode 100644
index 0000000..efaa9f7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
@@ -0,0 +1,130 @@
+/**
+ * @license
+ * Copyright (C) 2016 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.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-account-label';
+import {
+  queryAndAssert,
+  spyRestApi,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {GrAccountLabel} from './gr-account-label';
+import {AccountDetailInfo, ServerInfo} from '../../../types/common';
+import {
+  createAccountDetailWithId,
+  createChange,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-account-label');
+
+suite('gr-account-label tests', () => {
+  let element: GrAccountLabel;
+  const kermit: AccountDetailInfo = {
+    ...createAccountDetailWithId(31),
+    name: 'kermit',
+  };
+
+  setup(() => {
+    stubRestApi('getAccount').callsFake(() => Promise.resolve(kermit));
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    element = basicFixture.instantiate();
+    element._config = {
+      ...createServerInfo(),
+      user: {
+        anonymous_coward_name: 'Anonymous Coward',
+      },
+    };
+  });
+
+  suite('_computeName', () => {
+    test('not showing anonymous', () => {
+      const account = {name: 'Wyatt'};
+      assert.deepEqual(element._computeName(account), 'Wyatt');
+    });
+
+    test('showing anonymous but no config', () => {
+      const account = {};
+      assert.deepEqual(element._computeName(account), 'Anonymous');
+    });
+
+    test('test for Anonymous Coward user and replace with Anonymous', () => {
+      const config: ServerInfo = {
+        ...createServerInfo(),
+        user: {
+          anonymous_coward_name: 'Anonymous Coward',
+        },
+      };
+      const account = {};
+      assert.deepEqual(element._computeName(account, config), 'Anonymous');
+    });
+
+    test('test for anonymous_coward_name', () => {
+      const config = {
+        ...createServerInfo(),
+        user: {
+          anonymous_coward_name: 'TestAnon',
+        },
+      };
+      const account = {};
+      assert.deepEqual(element._computeName(account, config), 'TestAnon');
+    });
+  });
+
+  suite('attention set', () => {
+    setup(async () => {
+      element.highlightAttention = true;
+      element._config = {
+        ...createServerInfo(),
+        user: {anonymous_coward_name: 'Anonymous Coward'},
+      };
+      element._selfAccount = kermit;
+      element.account = {
+        ...createAccountDetailWithId(42),
+        name: 'ernie',
+      };
+      element.change = {
+        ...createChange(),
+        attention_set: {
+          42: {
+            account: createAccountDetailWithId(42),
+          },
+        },
+        owner: kermit,
+        reviewers: {},
+      };
+      await flush();
+    });
+
+    test('show attention button', () => {
+      const button = queryAndAssert(element, '#attentionButton');
+      assert.ok(button);
+      assert.isNull(button.getAttribute('disabled'));
+    });
+
+    test('tap attention button', async () => {
+      const apiSpy = spyRestApi('removeFromAttentionSet');
+      const button = queryAndAssert(element, '#attentionButton');
+      assert.ok(button);
+      assert.isNull(button.getAttribute('disabled'));
+      MockInteractions.tap(button);
+      assert.isTrue(apiSpy.calledOnce);
+      assert.equal(apiSpy.lastCall.args[1], 42);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 130969e..d97e38e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -29,7 +29,7 @@
   EmailAddress,
 } from '../../../types/common';
 import {
-  GrReviewerSuggestionsProvider,
+  ReviewerSuggestionsProvider,
   SuggestionItem,
 } from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
@@ -145,7 +145,7 @@
    * Returns suggestions and convert them to list item
    */
   @property({type: Object})
-  suggestionsProvider?: GrReviewerSuggestionsProvider;
+  suggestionsProvider?: ReviewerSuggestionsProvider;
 
   /**
    * Needed for template checking since value is initially set to null.
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
similarity index 64%
rename from polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
rename to polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
index 693f4cb..b667aba 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
@@ -14,46 +14,78 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-account-list.js';
+import '../../../test/common-test-setup-karma';
+import './gr-account-list';
+import {
+  AccountInfoInput,
+  GrAccountList,
+  RawAccountInput,
+} from './gr-account-list';
+import {
+  AccountId,
+  AccountInfo,
+  EmailAddress,
+  GroupId,
+  GroupInfo,
+  SuggestedReviewerAccountInfo,
+  Suggestion,
+} from '../../../types/common';
+import {queryAll} from '../../../test/test-utils';
+import {ReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-account-list');
 
-class MockSuggestionsProvider {
-  getSuggestions(input) {
+class MockSuggestionsProvider implements ReviewerSuggestionsProvider {
+  init() {}
+
+  getSuggestions(_: string): Promise<Suggestion[]> {
     return Promise.resolve([]);
   }
 
-  makeSuggestionItem(item) {
-    return item;
+  makeSuggestionItem(_: Suggestion) {
+    return {
+      name: 'test',
+      value: {
+        account: {
+          _account_id: 1 as AccountId,
+        } as AccountInfo,
+        count: 1,
+      } as SuggestedReviewerAccountInfo,
+    };
   }
 }
 
 suite('gr-account-list tests', () => {
   let _nextAccountId = 0;
-  const makeAccount = function() {
+  const makeAccount: () => AccountInfo = function () {
     const accountId = ++_nextAccountId;
     return {
-      _account_id: accountId,
+      _account_id: accountId as AccountId,
     };
   };
-  const makeGroup = function() {
-    const groupId = 'group' + (++_nextAccountId);
+  const makeGroup: () => GroupInfo = function () {
+    const groupId = `group${++_nextAccountId}`;
     return {
-      id: groupId,
+      id: groupId as GroupId,
       _group: true,
     };
   };
 
-  let existingAccount1;
-  let existingAccount2;
+  let existingAccount1: AccountInfo;
+  let existingAccount2: AccountInfo;
 
-  let element;
-  let suggestionsProvider;
+  let element: GrAccountList;
+  let suggestionsProvider: MockSuggestionsProvider;
 
   function getChips() {
-    return element.root.querySelectorAll('gr-account-chip');
+    return queryAll(element, 'gr-account-chip');
+  }
+
+  function handleAdd(value: RawAccountInput) {
+    element._handleAdd(
+      new CustomEvent<{value: RawAccountInput}>('add', {detail: {value}})
+    );
   }
 
   setup(() => {
@@ -84,13 +116,7 @@
 
     // New accounts are added to end with pendingAdd class.
     const newAccount = makeAccount();
-    element._handleAdd({
-      detail: {
-        value: {
-          account: newAccount,
-        },
-      },
-    });
+    handleAdd({account: newAccount});
     flush();
     chips = getChips();
     assert.equal(chips.length, 3);
@@ -100,10 +126,12 @@
 
     // Removed accounts are taken out of the list.
     element.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: existingAccount1},
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('remove', {
+        detail: {account: existingAccount1},
+        composed: true,
+        bubbles: true,
+      })
+    );
     flush();
     chips = getChips();
     assert.equal(chips.length, 2);
@@ -112,15 +140,19 @@
 
     // Invalid remove is ignored.
     element.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: existingAccount1},
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('remove', {
+        detail: {account: existingAccount1},
+        composed: true,
+        bubbles: true,
+      })
+    );
     element.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: newAccount},
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('remove', {
+        detail: {account: newAccount},
+        composed: true,
+        bubbles: true,
+      })
+    );
     flush();
     chips = getChips();
     assert.equal(chips.length, 1);
@@ -128,13 +160,7 @@
 
     // New groups are added to end with pendingAdd and group classes.
     const newGroup = makeGroup();
-    element._handleAdd({
-      detail: {
-        value: {
-          group: newGroup,
-        },
-      },
-    });
+    handleAdd({group: newGroup, confirm: false});
     flush();
     chips = getChips();
     assert.equal(chips.length, 2);
@@ -143,10 +169,12 @@
 
     // Removed groups are taken out of the list.
     element.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: newGroup},
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('remove', {
+        detail: {account: newGroup},
+        composed: true,
+        bubbles: true,
+      })
+    );
     flush();
     chips = getChips();
     assert.equal(chips.length, 1);
@@ -154,54 +182,67 @@
   });
 
   test('_getSuggestions uses filter correctly', () => {
-    const originalSuggestions = [
+    const originalSuggestions: Suggestion[] = [
       {
-        email: 'abc@example.com',
+        email: 'abc@example.com' as EmailAddress,
         text: 'abcd',
-        _account_id: 3,
-      },
+        _account_id: 3 as AccountId,
+      } as AccountInfo,
       {
-        email: 'qwe@example.com',
+        email: 'qwe@example.com' as EmailAddress,
         text: 'qwer',
-        _account_id: 1,
-      },
+        _account_id: 1 as AccountId,
+      } as AccountInfo,
       {
-        email: 'xyz@example.com',
+        email: 'xyz@example.com' as EmailAddress,
         text: 'aaaaa',
-        _account_id: 25,
-      },
+        _account_id: 25 as AccountId,
+      } as AccountInfo,
     ];
-    sinon.stub(suggestionsProvider, 'getSuggestions')
-        .returns(Promise.resolve(originalSuggestions));
-    sinon.stub(suggestionsProvider, 'makeSuggestionItem')
-        .callsFake( suggestion => {
-          return {
-            name: suggestion.email,
-            value: suggestion._account_id,
-          };
-        });
+    sinon
+      .stub(suggestionsProvider, 'getSuggestions')
+      .returns(Promise.resolve(originalSuggestions));
+    sinon
+      .stub(suggestionsProvider, 'makeSuggestionItem')
+      .callsFake(suggestion => {
+        return {
+          name: ((suggestion as AccountInfo).email as string) ?? '',
+          value: {
+            account: suggestion as AccountInfo,
+            count: 1,
+          },
+        };
+      });
 
-    return element._getSuggestions().then(suggestions => {
-      // Default is no filtering.
-      assert.equal(suggestions.length, 3);
+    return element
+      ._getSuggestions('')
+      .then(suggestions => {
+        // Default is no filtering.
+        assert.equal(suggestions.length, 3);
 
-      // Set up filter that only accepts suggestion1.
-      const accountId = originalSuggestions[0]._account_id;
-      element.filter = function(suggestion) {
-        return suggestion._account_id === accountId;
-      };
+        // Set up filter that only accepts suggestion1.
+        const accountId = (originalSuggestions[0] as AccountInfo)._account_id;
+        element.filter = function (suggestion) {
+          return (suggestion as AccountInfo)._account_id === accountId;
+        };
 
-      return element._getSuggestions();
-    })
-        .then(suggestions => {
-          assert.deepEqual(suggestions,
-              [{name: originalSuggestions[0].email,
-                value: originalSuggestions[0]._account_id}]);
-        });
+        return element._getSuggestions('');
+      })
+      .then(suggestions => {
+        assert.deepEqual(suggestions, [
+          {
+            name: (originalSuggestions[0] as AccountInfo).email as string,
+            value: {
+              account: originalSuggestions[0] as AccountInfo,
+              count: 1,
+            },
+          },
+        ]);
+      });
   });
 
   test('_computeChipClass', () => {
-    const account = makeAccount();
+    const account = makeAccount() as AccountInfoInput;
     assert.equal(element._computeChipClass(account), '');
     account._pendingAdd = true;
     assert.equal(element._computeChipClass(account), 'pendingAdd');
@@ -212,7 +253,7 @@
   });
 
   test('_computeRemovable', () => {
-    const newAccount = makeAccount();
+    const newAccount = makeAccount() as AccountInfoInput;
     newAccount._pendingAdd = true;
     element.readonly = false;
     element.removableValues = [];
@@ -250,28 +291,19 @@
     // When entry is valid, return true and clear text.
     assert.isTrue(element.submitEntryText());
     assert.isTrue(clearStub.called);
-    assert.equal(element.additions()[0].account.email, 'test@test');
+    assert.equal(
+      element.additions()[0].account?.email,
+      'test@test' as EmailAddress
+    );
   });
 
   test('additions returns sanitized new accounts and groups', () => {
     assert.equal(element.additions().length, 0);
 
     const newAccount = makeAccount();
-    element._handleAdd({
-      detail: {
-        value: {
-          account: newAccount,
-        },
-      },
-    });
+    handleAdd({account: newAccount});
     const newGroup = makeGroup();
-    element._handleAdd({
-      detail: {
-        value: {
-          group: newGroup,
-        },
-      },
-    });
+    handleAdd({group: newGroup, confirm: false});
 
     assert.deepEqual(element.additions(), [
       {
@@ -300,11 +332,7 @@
       count: 10,
       confirm: true,
     };
-    element._handleAdd({
-      detail: {
-        value: reviewer,
-      },
-    });
+    handleAdd(reviewer);
 
     assert.deepEqual(element.pendingConfirmation, reviewer);
     assert.deepEqual(element.additions(), []);
@@ -334,35 +362,30 @@
   test('max-count', () => {
     element.maxCount = 1;
     const acct = makeAccount();
-    element._handleAdd({
-      detail: {
-        value: {
-          account: acct,
-        },
-      },
-    });
+    handleAdd({account: acct});
     flush();
     assert.isTrue(element.$.entry.hasAttribute('hidden'));
   });
 
   test('enter text calls suggestions provider', async () => {
-    const suggestions = [
+    const suggestions: Suggestion[] = [
       {
-        email: 'abc@example.com',
+        email: 'abc@example.com' as EmailAddress,
         text: 'abcd',
-      },
+      } as AccountInfo,
       {
-        email: 'qwe@example.com',
+        email: 'qwe@example.com' as EmailAddress,
         text: 'qwer',
-      },
+      } as AccountInfo,
     ];
-    const getSuggestionsStub =
-        sinon.stub(suggestionsProvider, 'getSuggestions')
-            .returns(Promise.resolve(suggestions));
+    const getSuggestionsStub = sinon
+      .stub(suggestionsProvider, 'getSuggestions')
+      .returns(Promise.resolve(suggestions));
 
-    const makeSuggestionItemStub =
-        sinon.stub(suggestionsProvider, 'makeSuggestionItem')
-            .callsFake( item => item);
+    const makeSuggestionItemSpy = sinon.spy(
+      suggestionsProvider,
+      'makeSuggestionItem'
+    );
 
     const input = element.$.entry.$.input;
 
@@ -372,28 +395,29 @@
     await flush();
     assert.isTrue(getSuggestionsStub.calledOnce);
     assert.equal(getSuggestionsStub.lastCall.args[0], 'newTest');
-    assert.equal(makeSuggestionItemStub.getCalls().length, 2);
+    assert.equal(makeSuggestionItemSpy.getCalls().length, 2);
   });
 
   test('suggestion on empty', async () => {
     element.skipSuggestOnEmpty = false;
-    const suggestions = [
+    const suggestions: Suggestion[] = [
       {
-        email: 'abc@example.com',
+        email: 'abc@example.com' as EmailAddress,
         text: 'abcd',
-      },
+      } as AccountInfo,
       {
-        email: 'qwe@example.com',
+        email: 'qwe@example.com' as EmailAddress,
         text: 'qwer',
-      },
+      } as AccountInfo,
     ];
-    const getSuggestionsStub =
-        sinon.stub(suggestionsProvider, 'getSuggestions')
-            .returns(Promise.resolve(suggestions));
+    const getSuggestionsStub = sinon
+      .stub(suggestionsProvider, 'getSuggestions')
+      .returns(Promise.resolve(suggestions));
 
-    const makeSuggestionItemStub =
-        sinon.stub(suggestionsProvider, 'makeSuggestionItem')
-            .callsFake( item => item);
+    const makeSuggestionItemSpy = sinon.spy(
+      suggestionsProvider,
+      'makeSuggestionItem'
+    );
 
     const input = element.$.entry.$.input;
 
@@ -403,14 +427,14 @@
     await flush();
     assert.isTrue(getSuggestionsStub.calledOnce);
     assert.equal(getSuggestionsStub.lastCall.args[0], '');
-    assert.equal(makeSuggestionItemStub.getCalls().length, 2);
+    assert.equal(makeSuggestionItemSpy.getCalls().length, 2);
   });
 
   test('skip suggestion on empty', async () => {
     element.skipSuggestOnEmpty = true;
-    const getSuggestionsStub =
-        sinon.stub(suggestionsProvider, 'getSuggestions')
-            .returns(Promise.resolve([]));
+    const getSuggestionsStub = sinon
+      .stub(suggestionsProvider, 'getSuggestions')
+      .returns(Promise.resolve([]));
 
     const input = element.$.entry.$.input;
 
@@ -428,15 +452,18 @@
 
     test('adds emails', () => {
       const accountLen = element.accounts.length;
-      element._handleAdd({detail: {value: 'test@test'}});
+      handleAdd('test@test');
       assert.equal(element.accounts.length, accountLen + 1);
-      assert.equal(element.accounts[accountLen].email, 'test@test');
+      assert.equal(
+        (element.accounts[accountLen] as AccountInfoInput).email,
+        'test@test' as EmailAddress
+      );
     });
 
     test('toasts on invalid email', () => {
       const toastHandler = sinon.stub();
       element.addEventListener('show-alert', toastHandler);
-      element._handleAdd({detail: {value: 'test'}});
+      handleAdd('test');
       assert.isTrue(toastHandler.called);
     });
   });
@@ -449,18 +476,21 @@
       await flush();
       // Next line is a workaround for Firefox not moving cursor
       // on input field update
-      assert.equal(
-          element._getNativeInput(input.$.input).selectionStart, 0);
+      assert.equal(element._getNativeInput(input.$.input).selectionStart, 0);
       input.text = 'test';
       MockInteractions.focus(input.$.input);
       flush();
       assert.equal(element.accounts.length, 2);
       MockInteractions.pressAndReleaseKeyOn(
-          element._getNativeInput(input.$.input), 8); // Backspace
+        element._getNativeInput(input.$.input),
+        8
+      ); // Backspace
       assert.equal(element.accounts.length, 2);
       input.text = '';
       MockInteractions.pressAndReleaseKeyOn(
-          element._getNativeInput(input.$.input), 8); // Backspace
+        element._getNativeInput(input.$.input),
+        8
+      ); // Backspace
       flush();
       assert.equal(element.accounts.length, 1);
     });
@@ -490,15 +520,12 @@
       flush();
       const focusSpy = sinon.spy(element.accountChips[1], 'focus');
       const removeSpy = sinon.spy(element, 'removeAccount');
-      MockInteractions.pressAndReleaseKeyOn(
-          element.accountChips[0], 8); // Backspace
+      MockInteractions.pressAndReleaseKeyOn(element.accountChips[0], 8); // Backspace
       assert.isTrue(focusSpy.called);
       assert.isTrue(removeSpy.calledOnce);
 
-      MockInteractions.pressAndReleaseKeyOn(
-          element.accountChips[1], 46); // Delete
+      MockInteractions.pressAndReleaseKeyOn(element.accountChips[1], 46); // Delete
       assert.isTrue(removeSpy.calledTwice);
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index fdc72ce..73d1bf0 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -93,7 +93,8 @@
     };
   }
 
-  private cursor = new GrCursorManager();
+  // visible for testing
+  cursor = new GrCursorManager();
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
similarity index 77%
rename from polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js
rename to polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
index 200fddc..bb47dbc0 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
@@ -14,30 +14,36 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-autocomplete-dropdown.js';
+import '../../../test/common-test-setup-karma';
+import './gr-autocomplete-dropdown';
+import {GrAutocompleteDropdown} from './gr-autocomplete-dropdown';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const basicFixture = fixtureFromElement('gr-autocomplete-dropdown');
 
 suite('gr-autocomplete-dropdown', () => {
-  let element;
+  let element: GrAutocompleteDropdown;
 
-  setup(() => {
+  const suggestionsEl = () => queryAndAssert(element, '#suggestions');
+
+  setup(async () => {
     element = basicFixture.instantiate();
     element.open();
     element.suggestions = [
-      {dataValue: 'test value 1', name: 'test name 1', text: 1, label: 'hi'},
-      {dataValue: 'test value 2', name: 'test name 2', text: 2}];
-    flush();
+      {dataValue: 'test value 1', name: 'test name 1', text: '1', label: 'hi'},
+      {dataValue: 'test value 2', name: 'test name 2', text: '2'},
+    ];
+    await flush();
   });
 
   teardown(() => {
-    if (element.isOpen) element.close();
+    element.close();
   });
 
   test('shows labels', () => {
-    const els = element.$.suggestions.querySelectorAll('li');
+    const els = queryAll<HTMLElement>(suggestionsEl(), 'li');
     assert.equal(els[0].innerText.trim(), '1\nhi');
     assert.equal(els[1].innerText.trim(), '2');
   });
@@ -106,24 +112,25 @@
     const itemSelectedStub = sinon.stub();
     element.addEventListener('item-selected', itemSelectedStub);
 
-    MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[1]);
+    MockInteractions.tap(suggestionsEl().querySelectorAll('li')[1]);
     flush();
     assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
       trigger: 'click',
-      selected: element.$.suggestions.querySelectorAll('li')[1],
+      selected: suggestionsEl().querySelectorAll('li')[1],
     });
   });
 
   test('tapping child still selects item', () => {
     const itemSelectedStub = sinon.stub();
     element.addEventListener('item-selected', itemSelectedStub);
-
-    MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[0]
-        .lastElementChild);
+    const lastElChild = queryAll<HTMLElement>(suggestionsEl(), 'li')[0]
+      ?.lastElementChild;
+    assertIsDefined(lastElChild);
+    MockInteractions.tap(lastElChild);
     flush();
     assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
       trigger: 'click',
-      selected: element.$.suggestions.querySelectorAll('li')[0],
+      selected: queryAll<HTMLElement>(suggestionsEl(), 'li')[0],
     });
   });
 
@@ -133,4 +140,3 @@
     assert.isTrue(resetStopsSpy.called);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index 1ff5350..9a7b18f 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -341,7 +341,7 @@
     return this.$.suggestions.close();
   }
 
-  _computeClass(borderless: boolean) {
+  _computeClass(borderless?: boolean) {
     return borderless ? 'borderless' : '';
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
deleted file mode 100644
index d72007e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
+++ /dev/null
@@ -1,579 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 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.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-autocomplete.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-
-const basicFixture = fixtureFromTemplate(
-    html`<gr-autocomplete no-debounce></gr-autocomplete>`);
-
-suite('gr-autocomplete tests', () => {
-  let element;
-
-  const focusOnInput = element => {
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-        'enter');
-  };
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('renders', () => {
-    let promise;
-    const queryStub = sinon.spy(input => promise = Promise.resolve([
-      {name: input + ' 0', value: 0},
-      {name: input + ' 1', value: 1},
-      {name: input + ' 2', value: 2},
-      {name: input + ' 3', value: 3},
-      {name: input + ' 4', value: 4},
-    ]));
-    element.query = queryStub;
-    assert.isTrue(element.$.suggestions.isHidden);
-    assert.equal(element.$.suggestions.cursor.index, -1);
-
-    focusOnInput(element);
-    element.text = 'blah';
-
-    assert.isTrue(queryStub.called);
-    element._focused = true;
-
-    return promise.then(() => {
-      assert.isFalse(element.$.suggestions.isHidden);
-      const suggestions =
-          element.$.suggestions.root.querySelectorAll('li');
-      assert.equal(suggestions.length, 5);
-
-      for (let i = 0; i < 5; i++) {
-        assert.equal(suggestions[i].innerText.trim(), 'blah ' + i);
-      }
-
-      assert.notEqual(element.$.suggestions.cursor.index, -1);
-    });
-  });
-
-  test('selectAll', async () => {
-    await flush();
-    const nativeInput = element._nativeInput;
-    const selectionStub = sinon.stub(nativeInput, 'setSelectionRange');
-
-    element.selectAll();
-    assert.isFalse(selectionStub.called);
-
-    element.$.input.value = 'test';
-    element.selectAll();
-    assert.isTrue(selectionStub.called);
-  });
-
-  test('esc key behavior', () => {
-    let promise;
-    const queryStub = sinon.spy(() => promise = Promise.resolve([
-      {name: 'blah', value: 123},
-    ]));
-    element.query = queryStub;
-
-    assert.isTrue(element.$.suggestions.isHidden);
-
-    element._focused = true;
-    element.text = 'blah';
-
-    return promise.then(() => {
-      assert.isFalse(element.$.suggestions.isHidden);
-
-      const cancelHandler = sinon.spy();
-      element.addEventListener('cancel', cancelHandler);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
-      assert.isFalse(cancelHandler.called);
-      assert.isTrue(element.$.suggestions.isHidden);
-      assert.equal(element._suggestions.length, 0);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
-      assert.isTrue(cancelHandler.called);
-    });
-  });
-
-  test('emits commit and handles cursor movement', () => {
-    let promise;
-    const queryStub = sinon.spy(input => promise = Promise.resolve([
-      {name: input + ' 0', value: 0},
-      {name: input + ' 1', value: 1},
-      {name: input + ' 2', value: 2},
-      {name: input + ' 3', value: 3},
-      {name: input + ' 4', value: 4},
-    ]));
-    element.query = queryStub;
-
-    assert.isTrue(element.$.suggestions.isHidden);
-    assert.equal(element.$.suggestions.cursor.index, -1);
-    element._focused = true;
-    element.text = 'blah';
-
-    return promise.then(() => {
-      assert.isFalse(element.$.suggestions.isHidden);
-
-      const commitHandler = sinon.spy();
-      element.addEventListener('commit', commitHandler);
-
-      assert.equal(element.$.suggestions.cursor.index, 0);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
-          'down');
-
-      assert.equal(element.$.suggestions.cursor.index, 1);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
-          'down');
-
-      assert.equal(element.$.suggestions.cursor.index, 2);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 38, null, 'up');
-
-      assert.equal(element.$.suggestions.cursor.index, 1);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-
-      assert.equal(element.value, 1);
-      assert.isTrue(commitHandler.called);
-      assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
-      assert.isTrue(element.$.suggestions.isHidden);
-      assert.isTrue(element._focused);
-    });
-  });
-
-  test('clear-on-commit behavior (off)', () => {
-    let promise;
-    const queryStub = sinon.spy(() => {
-      promise = Promise.resolve([{name: 'suggestion', value: 0}]);
-      return promise;
-    });
-    element.query = queryStub;
-    focusOnInput(element);
-    element.text = 'blah';
-
-    return promise.then(() => {
-      const commitHandler = sinon.spy();
-      element.addEventListener('commit', commitHandler);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-
-      assert.isTrue(commitHandler.called);
-      assert.equal(element.text, 'suggestion');
-    });
-  });
-
-  test('clear-on-commit behavior (on)', () => {
-    let promise;
-    const queryStub = sinon.spy(() => {
-      promise = Promise.resolve([{name: 'suggestion', value: 0}]);
-      return promise;
-    });
-    element.query = queryStub;
-    focusOnInput(element);
-    element.text = 'blah';
-    element.clearOnCommit = true;
-
-    return promise.then(() => {
-      const commitHandler = sinon.spy();
-      element.addEventListener('commit', commitHandler);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-
-      assert.isTrue(commitHandler.called);
-      assert.equal(element.text, '');
-    });
-  });
-
-  test('threshold guards the query', () => {
-    const queryStub = sinon.spy(() => Promise.resolve([]));
-    element.query = queryStub;
-    element.threshold = 2;
-    focusOnInput(element);
-    element.text = 'a';
-    assert.isFalse(queryStub.called);
-    element.text = 'ab';
-    assert.isTrue(queryStub.called);
-  });
-
-  test('noDebounce=false debounces the query', () => {
-    const clock = sinon.useFakeTimers();
-    const queryStub = sinon.spy(() => Promise.resolve([]));
-    element.query = queryStub;
-    element.noDebounce = false;
-    focusOnInput(element);
-    element.text = 'a';
-
-    // not called right away
-    assert.isFalse(queryStub.called);
-
-    // but called after a while
-    clock.tick(1000);
-    assert.isTrue(queryStub.called);
-  });
-
-  test('_computeClass respects border property', () => {
-    assert.equal(element._computeClass(), '');
-    assert.equal(element._computeClass(false), '');
-    assert.equal(element._computeClass(true), 'borderless');
-  });
-
-  test('undefined or empty text results in no suggestions', () => {
-    element._updateSuggestions(undefined, 0, null);
-    assert.equal(element._suggestions.length, 0);
-  });
-
-  test('when focused', () => {
-    let promise;
-    const queryStub = sinon.stub()
-        .returns(promise = Promise.resolve([
-          {name: 'suggestion', value: 0},
-        ]));
-    element.query = queryStub;
-    element.suggestOnlyWhenFocus = true;
-    focusOnInput(element);
-    element.text = 'bla';
-    assert.equal(element._focused, true);
-    flush();
-    return promise.then(() => {
-      assert.equal(element._suggestions.length, 1);
-      assert.equal(queryStub.notCalled, false);
-    });
-  });
-
-  test('when not focused', () => {
-    let promise;
-    const queryStub = sinon.stub()
-        .returns(promise = Promise.resolve([
-          {name: 'suggestion', value: 0},
-        ]));
-    element.query = queryStub;
-    element.suggestOnlyWhenFocus = true;
-    element.text = 'bla';
-    assert.equal(element._focused, false);
-    flush();
-    return promise.then(() => {
-      assert.equal(element._suggestions.length, 0);
-    });
-  });
-
-  test('suggestions should not carry over', () => {
-    let promise;
-    const queryStub = sinon.stub()
-        .returns(promise = Promise.resolve([
-          {name: 'suggestion', value: 0},
-        ]));
-    element.query = queryStub;
-    focusOnInput(element);
-    element.text = 'bla';
-    flush();
-    return promise.then(() => {
-      assert.equal(element._suggestions.length, 1);
-      element._updateSuggestions('', 0, false);
-      assert.equal(element._suggestions.length, 0);
-    });
-  });
-
-  test('multi completes only the last part of the query', () => {
-    let promise;
-    const queryStub = sinon.stub()
-        .returns(promise = Promise.resolve([
-          {name: 'suggestion', value: 0},
-        ]));
-    element.query = queryStub;
-    focusOnInput(element);
-    element.text = 'blah blah';
-    element.multi = true;
-
-    return promise.then(() => {
-      const commitHandler = sinon.spy();
-      element.addEventListener('commit', commitHandler);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-
-      assert.isTrue(commitHandler.called);
-      assert.equal(element.text, 'blah 0');
-    });
-  });
-
-  test('tabComplete flag functions', () => {
-    // commitHandler checks for the commit event, whereas commitSpy checks for
-    // the _commit function of the element.
-    const commitHandler = sinon.spy();
-    element.addEventListener('commit', commitHandler);
-    const commitSpy = sinon.spy(element, '_commit');
-    element._focused = true;
-
-    element._suggestions = ['tunnel snakes rule!'];
-    element.tabComplete = false;
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-    assert.isFalse(commitHandler.called);
-    assert.isFalse(commitSpy.called);
-    assert.isFalse(element._focused);
-
-    element.tabComplete = true;
-    element._focused = true;
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-    assert.isFalse(commitHandler.called);
-    assert.isTrue(commitSpy.called);
-    assert.isTrue(element._focused);
-  });
-
-  test('_focused flag properly triggered', () => {
-    flush();
-    assert.isFalse(element._focused);
-    const input = element.shadowRoot
-        .querySelector('paper-input').inputElement;
-    MockInteractions.focus(input);
-    assert.isTrue(element._focused);
-  });
-
-  test('search icon shows with showSearchIcon property', () => {
-    flush();
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('iron-icon')).display,
-    'none');
-    element.showSearchIcon = true;
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('iron-icon')).display,
-    'none');
-  });
-
-  test('vertical offset overridden by param if it exists', () => {
-    assert.equal(element.$.suggestions.verticalOffset, 31);
-    element.verticalOffset = 30;
-    assert.equal(element.$.suggestions.verticalOffset, 30);
-  });
-
-  test('_focused flag shows/hides the suggestions', () => {
-    const openStub = sinon.stub(element.$.suggestions, 'open');
-    const closedStub = sinon.stub(element.$.suggestions, 'close');
-    element._suggestions = ['hello', 'its me'];
-    assert.isFalse(openStub.called);
-    assert.isTrue(closedStub.calledOnce);
-    element._focused = true;
-    assert.isTrue(openStub.calledOnce);
-    element._suggestions = [];
-    assert.isTrue(closedStub.calledTwice);
-    assert.isTrue(openStub.calledOnce);
-  });
-
-  test('_handleInputCommit with autocomplete hidden does nothing without' +
-        'without allowNonSuggestedValues', () => {
-    const commitStub = sinon.stub(element, '_commit');
-    element.$.suggestions.isHidden = true;
-    element._handleInputCommit();
-    assert.isFalse(commitStub.called);
-  });
-
-  test('_handleInputCommit with autocomplete hidden with' +
-        'allowNonSuggestedValues', () => {
-    const commitStub = sinon.stub(element, '_commit');
-    element.allowNonSuggestedValues = true;
-    element.$.suggestions.isHidden = true;
-    element._handleInputCommit();
-    assert.isTrue(commitStub.called);
-  });
-
-  test('_handleInputCommit with autocomplete open calls commit', () => {
-    const commitStub = sinon.stub(element, '_commit');
-    element.$.suggestions.isHidden = false;
-    element._handleInputCommit();
-    assert.isTrue(commitStub.calledOnce);
-  });
-
-  test('_handleInputCommit with autocomplete open calls commit' +
-        'with allowNonSuggestedValues', () => {
-    const commitStub = sinon.stub(element, '_commit');
-    element.allowNonSuggestedValues = true;
-    element.$.suggestions.isHidden = false;
-    element._handleInputCommit();
-    assert.isTrue(commitStub.calledOnce);
-  });
-
-  test('issue 8655', () => {
-    function makeSuggestion(s) { return {name: s, text: s, value: s}; }
-    const keydownSpy = sinon.spy(element, '_handleKeydown');
-    element.setText('file:');
-    element._suggestions =
-        [makeSuggestion('file:'), makeSuggestion('-file:')];
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 88, null, 'x');
-    // Must set the value, because the MockInteraction does not.
-    element.$.input.value = 'file:x';
-    assert.isTrue(keydownSpy.calledOnce);
-    MockInteractions.pressAndReleaseKeyOn(
-        element.$.input,
-        13,
-        null,
-        'enter'
-    );
-    assert.isTrue(keydownSpy.calledTwice);
-    assert.equal(element.text, 'file:x');
-  });
-
-  suite('focus', () => {
-    let commitSpy;
-    let focusSpy;
-
-    setup(() => {
-      commitSpy = sinon.spy(element, '_commit');
-    });
-
-    test('enter does not call focus', () => {
-      element._suggestions = ['sugar bombs'];
-      focusSpy = sinon.spy(element, 'focus');
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-      flush();
-
-      assert.isTrue(commitSpy.called);
-      assert.isFalse(focusSpy.called);
-      assert.equal(element._suggestions.length, 0);
-    });
-
-    test('tab in input, tabComplete = true', () => {
-      focusSpy = sinon.spy(element, 'focus');
-      const commitHandler = sinon.stub();
-      element.addEventListener('commit', commitHandler);
-      element.tabComplete = true;
-      element._suggestions = ['tunnel snakes drool'];
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-      flush();
-
-      assert.isTrue(commitSpy.called);
-      assert.isTrue(focusSpy.called);
-      assert.isFalse(commitHandler.called);
-      assert.equal(element._suggestions.length, 0);
-    });
-
-    test('tab in input, tabComplete = false', () => {
-      element._suggestions = ['sugar bombs'];
-      focusSpy = sinon.spy(element, 'focus');
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-      flush();
-
-      assert.isFalse(commitSpy.called);
-      assert.isFalse(focusSpy.called);
-      assert.equal(element._suggestions.length, 1);
-    });
-
-    test('tab on suggestion, tabComplete = false', () => {
-      element._suggestions = [{name: 'sugar bombs'}];
-      element._focused = true;
-      // When tabComplete is false, do not focus.
-      element.tabComplete = false;
-      focusSpy = sinon.spy(element, 'focus');
-      flush$0();
-      assert.isFalse(element.$.suggestions.isHidden);
-
-      MockInteractions.pressAndReleaseKeyOn(
-          element.$.suggestions.shadowRoot
-              .querySelector('li:first-child'), 9, null, 'tab');
-      flush();
-      assert.isFalse(commitSpy.called);
-      assert.isFalse(element._focused);
-    });
-
-    test('tab on suggestion, tabComplete = true', () => {
-      element._suggestions = [{name: 'sugar bombs'}];
-      element._focused = true;
-      // When tabComplete is true, focus.
-      element.tabComplete = true;
-      focusSpy = sinon.spy(element, 'focus');
-      flush$0();
-      assert.isFalse(element.$.suggestions.isHidden);
-
-      MockInteractions.pressAndReleaseKeyOn(
-          element.$.suggestions.shadowRoot
-              .querySelector('li:first-child'), 9, null, 'tab');
-      flush();
-
-      assert.isTrue(commitSpy.called);
-      assert.isTrue(element._focused);
-    });
-
-    test('tap on suggestion commits, does not call focus', () => {
-      focusSpy = sinon.spy(element, 'focus');
-      element._focused = true;
-      element._suggestions = [{name: 'first suggestion'}];
-      flush$0();
-      assert.isFalse(element.$.suggestions.isHidden);
-      MockInteractions.tap(element.$.suggestions.shadowRoot
-          .querySelector('li:first-child'));
-      flush();
-
-      assert.isFalse(focusSpy.called);
-      assert.isTrue(commitSpy.called);
-      assert.isTrue(element.$.suggestions.isHidden);
-    });
-  });
-
-  test('input-keydown event fired', () => {
-    const listener = sinon.spy();
-    element.addEventListener('input-keydown', listener);
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-    flush();
-    assert.isTrue(listener.called);
-  });
-
-  test('enter with modifier does not complete', () => {
-    const handleSpy = sinon.spy(element, '_handleKeydown');
-    const commitStub = sinon.stub(element, '_handleInputCommit');
-    MockInteractions.pressAndReleaseKeyOn(
-        element.$.input, 13, 'ctrl', 'enter');
-    assert.isTrue(handleSpy.called);
-    assert.isFalse(commitStub.called);
-    MockInteractions.pressAndReleaseKeyOn(
-        element.$.input, 13, null, 'enter');
-    assert.isTrue(commitStub.called);
-  });
-
-  suite('warnUncommitted', () => {
-    let inputClassList;
-    setup(() => {
-      inputClassList = element.$.input.classList;
-    });
-
-    test('enabled', () => {
-      element.warnUncommitted = true;
-      element.text = 'blah blah blah';
-      MockInteractions.blur(element.$.input);
-      assert.isTrue(inputClassList.contains('warnUncommitted'));
-      MockInteractions.focus(element.$.input);
-      assert.isFalse(inputClassList.contains('warnUncommitted'));
-    });
-
-    test('disabled', () => {
-      element.warnUncommitted = false;
-      element.text = 'blah blah blah';
-      MockInteractions.blur(element.$.input);
-      assert.isFalse(inputClassList.contains('warnUncommitted'));
-    });
-
-    test('no text', () => {
-      element.warnUncommitted = true;
-      element.text = '';
-      MockInteractions.blur(element.$.input);
-      assert.isFalse(inputClassList.contains('warnUncommitted'));
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
new file mode 100644
index 0000000..6fe5e15
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
@@ -0,0 +1,619 @@
+/**
+ * @license
+ * Copyright (C) 2016 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.
+ */
+import '../../../test/common-test-setup-karma';
+import './gr-autocomplete';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {AutocompleteSuggestion, GrAutocomplete} from './gr-autocomplete';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {assertIsDefined} from '../../../utils/common-util';
+import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
+
+const basicFixture = fixtureFromTemplate(
+  html`<gr-autocomplete no-debounce></gr-autocomplete>`
+);
+
+suite('gr-autocomplete tests', () => {
+  let element: GrAutocomplete;
+
+  const focusOnInput = () => {
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+  };
+
+  const suggestionsEl = () =>
+    queryAndAssert<GrAutocompleteDropdown>(element, '#suggestions');
+
+  const inputEl = () => queryAndAssert<HTMLInputElement>(element, '#input');
+
+  setup(() => {
+    element = basicFixture.instantiate() as GrAutocomplete;
+  });
+
+  test('renders', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(
+      (input: string) =>
+        (promise = Promise.resolve([
+          {name: input + ' 0', value: '0'},
+          {name: input + ' 1', value: '1'},
+          {name: input + ' 2', value: '2'},
+          {name: input + ' 3', value: '3'},
+          {name: input + ' 4', value: '4'},
+        ] as AutocompleteSuggestion[]))
+    );
+    element.query = queryStub;
+    assert.isTrue(suggestionsEl().isHidden);
+    assert.equal(suggestionsEl().cursor.index, -1);
+
+    focusOnInput();
+    element.text = 'blah';
+
+    assert.isTrue(queryStub.called);
+    element._focused = true;
+
+    assertIsDefined(promise);
+    return promise.then(() => {
+      assert.isFalse(suggestionsEl().isHidden);
+      const suggestions = queryAll<HTMLElement>(suggestionsEl(), 'li');
+      assert.equal(suggestions.length, 5);
+
+      for (let i = 0; i < 5; i++) {
+        assert.equal(suggestions[i].innerText.trim(), `blah ${i}`);
+      }
+
+      assert.notEqual(suggestionsEl().cursor.index, -1);
+    });
+  });
+
+  test('selectAll', async () => {
+    await flush();
+    const nativeInput = element._nativeInput;
+    const selectionStub = sinon.stub(nativeInput, 'setSelectionRange');
+
+    element.selectAll();
+    assert.isFalse(selectionStub.called);
+
+    inputEl().value = 'test';
+    element.selectAll();
+    assert.isTrue(selectionStub.called);
+  });
+
+  test('esc key behavior', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(
+      (_: string) =>
+        (promise = Promise.resolve([
+          {name: 'blah', value: '123'},
+        ] as AutocompleteSuggestion[]))
+    );
+    element.query = queryStub;
+
+    assert.isTrue(suggestionsEl().isHidden);
+
+    element._focused = true;
+    element.text = 'blah';
+
+    return promise.then(() => {
+      assert.isFalse(suggestionsEl().isHidden);
+
+      const cancelHandler = sinon.spy();
+      element.addEventListener('cancel', cancelHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 27, null, 'esc');
+      assert.isFalse(cancelHandler.called);
+      assert.isTrue(suggestionsEl().isHidden);
+      assert.equal(element._suggestions.length, 0);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 27, null, 'esc');
+      assert.isTrue(cancelHandler.called);
+    });
+  });
+
+  test('emits commit and handles cursor movement', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(
+      (input: string) =>
+        (promise = Promise.resolve([
+          {name: input + ' 0', value: '0'},
+          {name: input + ' 1', value: '1'},
+          {name: input + ' 2', value: '2'},
+          {name: input + ' 3', value: '3'},
+          {name: input + ' 4', value: '4'},
+        ] as AutocompleteSuggestion[]))
+    );
+    element.query = queryStub;
+
+    assert.isTrue(suggestionsEl().isHidden);
+    assert.equal(suggestionsEl().cursor.index, -1);
+    element._focused = true;
+    element.text = 'blah';
+
+    return promise.then(() => {
+      assert.isFalse(suggestionsEl().isHidden);
+
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      assert.equal(suggestionsEl().cursor.index, 0);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 40, null, 'down');
+
+      assert.equal(suggestionsEl().cursor.index, 1);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 40, null, 'down');
+
+      assert.equal(suggestionsEl().cursor.index, 2);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 38, null, 'up');
+
+      assert.equal(suggestionsEl().cursor.index, 1);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+
+      assert.equal(element.value, '1');
+      assert.isTrue(commitHandler.called);
+      assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
+      assert.isTrue(suggestionsEl().isHidden);
+      assert.isTrue(element._focused);
+    });
+  });
+
+  test('clear-on-commit behavior (off)', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(() => {
+      promise = Promise.resolve([
+        {name: 'suggestion', value: '0'},
+      ] as AutocompleteSuggestion[]);
+      return promise;
+    });
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'blah';
+
+    return promise.then(() => {
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, 'suggestion');
+    });
+  });
+
+  test('clear-on-commit behavior (on)', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(() => {
+      promise = Promise.resolve([
+        {name: 'suggestion', value: '0'},
+      ] as AutocompleteSuggestion[]);
+      return promise;
+    });
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'blah';
+    element.clearOnCommit = true;
+
+    return promise.then(() => {
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, '');
+    });
+  });
+
+  test('threshold guards the query', () => {
+    const queryStub = sinon.spy(() =>
+      Promise.resolve([] as AutocompleteSuggestion[])
+    );
+    element.query = queryStub;
+    element.threshold = 2;
+    focusOnInput();
+    element.text = 'a';
+    assert.isFalse(queryStub.called);
+    element.text = 'ab';
+    assert.isTrue(queryStub.called);
+  });
+
+  test('noDebounce=false debounces the query', () => {
+    const clock = sinon.useFakeTimers();
+    const queryStub = sinon.spy(() =>
+      Promise.resolve([] as AutocompleteSuggestion[])
+    );
+    element.query = queryStub;
+    element.noDebounce = false;
+    focusOnInput();
+    element.text = 'a';
+
+    // not called right away
+    assert.isFalse(queryStub.called);
+
+    // but called after a while
+    clock.tick(1000);
+    assert.isTrue(queryStub.called);
+  });
+
+  test('_computeClass respects border property', () => {
+    assert.equal(element._computeClass(), '');
+    assert.equal(element._computeClass(false), '');
+    assert.equal(element._computeClass(true), 'borderless');
+  });
+
+  test('undefined or empty text results in no suggestions', () => {
+    element._updateSuggestions(undefined, 0, undefined);
+    assert.equal(element._suggestions.length, 0);
+  });
+
+  test('when focused', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon
+      .stub()
+      .returns(
+        (promise = Promise.resolve([
+          {name: 'suggestion', value: '0'},
+        ] as AutocompleteSuggestion[]))
+      );
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'bla';
+    assert.equal(element._focused, true);
+    flush();
+    return promise.then(() => {
+      assert.equal(element._suggestions.length, 1);
+      assert.equal(queryStub.notCalled, false);
+    });
+  });
+
+  test('when not focused', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon
+      .stub()
+      .returns(
+        (promise = Promise.resolve([
+          {name: 'suggestion', value: '0'},
+        ] as AutocompleteSuggestion[]))
+      );
+    element.query = queryStub;
+    element.text = 'bla';
+    assert.equal(element._focused, false);
+    flush();
+    return promise.then(() => {
+      assert.equal(element._suggestions.length, 0);
+    });
+  });
+
+  test('suggestions should not carry over', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon
+      .stub()
+      .returns(
+        (promise = Promise.resolve([
+          {name: 'suggestion', value: '0'},
+        ] as AutocompleteSuggestion[]))
+      );
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'bla';
+    flush();
+    return promise.then(() => {
+      assert.equal(element._suggestions.length, 1);
+      element._updateSuggestions('', 0, false);
+      assert.equal(element._suggestions.length, 0);
+    });
+  });
+
+  test('multi completes only the last part of the query', () => {
+    let promise;
+    const queryStub = sinon
+      .stub()
+      .returns(
+        (promise = Promise.resolve([
+          {name: 'suggestion', value: '0'},
+        ] as AutocompleteSuggestion[]))
+      );
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'blah blah';
+    element.multi = true;
+
+    return promise.then(() => {
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, 'blah 0');
+    });
+  });
+
+  test('tabComplete flag functions', () => {
+    // commitHandler checks for the commit event, whereas commitSpy checks for
+    // the _commit function of the element.
+    const commitHandler = sinon.spy();
+    element.addEventListener('commit', commitHandler);
+    const commitSpy = sinon.spy(element, '_commit');
+    element._focused = true;
+
+    element._suggestions = [{text: 'tunnel snakes rule!'}];
+    element.tabComplete = false;
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+    assert.isFalse(commitHandler.called);
+    assert.isFalse(commitSpy.called);
+    assert.isFalse(element._focused);
+
+    element.tabComplete = true;
+    element._focused = true;
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+    assert.isFalse(commitHandler.called);
+    assert.isTrue(commitSpy.called);
+    assert.isTrue(element._focused);
+  });
+
+  test('_focused flag properly triggered', () => {
+    flush();
+    assert.isFalse(element._focused);
+    const input = queryAndAssert<PaperInputElement>(element, 'paper-input')
+      .inputElement;
+    MockInteractions.focus(input);
+    assert.isTrue(element._focused);
+  });
+
+  test('search icon shows with showSearchIcon property', () => {
+    flush();
+    assert.equal(
+      getComputedStyle(queryAndAssert(element, 'iron-icon')).display,
+      'none'
+    );
+    element.showSearchIcon = true;
+    assert.notEqual(
+      getComputedStyle(queryAndAssert(element, 'iron-icon')).display,
+      'none'
+    );
+  });
+
+  test('vertical offset overridden by param if it exists', () => {
+    assert.equal(suggestionsEl().verticalOffset, 31);
+    element.verticalOffset = 30;
+    assert.equal(suggestionsEl().verticalOffset, 30);
+  });
+
+  test('_focused flag shows/hides the suggestions', () => {
+    const openStub = sinon.stub(suggestionsEl(), 'open');
+    const closedStub = sinon.stub(suggestionsEl(), 'close');
+    element._suggestions = [{text: 'hello'}, {text: 'its me'}];
+    assert.isFalse(openStub.called);
+    assert.isTrue(closedStub.calledOnce);
+    element._focused = true;
+    assert.isTrue(openStub.calledOnce);
+    element._suggestions = [];
+    assert.isTrue(closedStub.calledTwice);
+    assert.isTrue(openStub.calledOnce);
+  });
+
+  test(
+    '_handleInputCommit with autocomplete hidden does nothing without' +
+      'without allowNonSuggestedValues',
+    () => {
+      const commitStub = sinon.stub(element, '_commit');
+      suggestionsEl().isHidden = true;
+      element._handleInputCommit();
+      assert.isFalse(commitStub.called);
+    }
+  );
+
+  test(
+    '_handleInputCommit with autocomplete hidden with' +
+      'allowNonSuggestedValues',
+    () => {
+      const commitStub = sinon.stub(element, '_commit');
+      element.allowNonSuggestedValues = true;
+      suggestionsEl().isHidden = true;
+      element._handleInputCommit();
+      assert.isTrue(commitStub.called);
+    }
+  );
+
+  test('_handleInputCommit with autocomplete open calls commit', () => {
+    const commitStub = sinon.stub(element, '_commit');
+    suggestionsEl().isHidden = false;
+    element._handleInputCommit();
+    assert.isTrue(commitStub.calledOnce);
+  });
+
+  test(
+    '_handleInputCommit with autocomplete open calls commit' +
+      'with allowNonSuggestedValues',
+    () => {
+      const commitStub = sinon.stub(element, '_commit');
+      element.allowNonSuggestedValues = true;
+      suggestionsEl().isHidden = false;
+      element._handleInputCommit();
+      assert.isTrue(commitStub.calledOnce);
+    }
+  );
+
+  test('issue 8655', () => {
+    function makeSuggestion(s: string) {
+      return {name: s, text: s, value: s};
+    }
+    const keydownSpy = sinon.spy(element, '_handleKeydown');
+    element.setText('file:');
+    element._suggestions = [makeSuggestion('file:'), makeSuggestion('-file:')];
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 88, null, 'x');
+    // Must set the value, because the MockInteraction does not.
+    inputEl().value = 'file:x';
+    assert.isTrue(keydownSpy.calledOnce);
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+    assert.isTrue(keydownSpy.calledTwice);
+    assert.equal(element.text, 'file:x');
+  });
+
+  suite('focus', () => {
+    let commitSpy: sinon.SinonSpy;
+    let focusSpy: sinon.SinonSpy;
+
+    setup(() => {
+      commitSpy = sinon.spy(element, '_commit');
+    });
+
+    test('enter does not call focus', () => {
+      element._suggestions = [{text: 'sugar bombs'}];
+      focusSpy = sinon.spy(element, 'focus');
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+      flush();
+
+      assert.isTrue(commitSpy.called);
+      assert.isFalse(focusSpy.called);
+      assert.equal(element._suggestions.length, 0);
+    });
+
+    test('tab in input, tabComplete = true', () => {
+      focusSpy = sinon.spy(element, 'focus');
+      const commitHandler = sinon.stub();
+      element.addEventListener('commit', commitHandler);
+      element.tabComplete = true;
+      element._suggestions = [{text: 'tunnel snakes drool'}];
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+      flush();
+
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(focusSpy.called);
+      assert.isFalse(commitHandler.called);
+      assert.equal(element._suggestions.length, 0);
+    });
+
+    test('tab in input, tabComplete = false', () => {
+      element._suggestions = [{text: 'sugar bombs'}];
+      focusSpy = sinon.spy(element, 'focus');
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+      flush();
+
+      assert.isFalse(commitSpy.called);
+      assert.isFalse(focusSpy.called);
+      assert.equal(element._suggestions.length, 1);
+    });
+
+    test('tab on suggestion, tabComplete = false', () => {
+      element._suggestions = [{name: 'sugar bombs'}];
+      element._focused = true;
+      // When tabComplete is false, do not focus.
+      element.tabComplete = false;
+      focusSpy = sinon.spy(element, 'focus');
+      flush$0();
+      assert.isFalse(suggestionsEl().isHidden);
+
+      MockInteractions.pressAndReleaseKeyOn(
+        queryAndAssert(suggestionsEl(), 'li:first-child'),
+        9,
+        null,
+        'tab'
+      );
+      flush();
+      assert.isFalse(commitSpy.called);
+      assert.isFalse(element._focused);
+    });
+
+    test('tab on suggestion, tabComplete = true', () => {
+      element._suggestions = [{name: 'sugar bombs'}];
+      element._focused = true;
+      // When tabComplete is true, focus.
+      element.tabComplete = true;
+      focusSpy = sinon.spy(element, 'focus');
+      flush$0();
+      assert.isFalse(suggestionsEl().isHidden);
+
+      MockInteractions.pressAndReleaseKeyOn(
+        queryAndAssert(suggestionsEl(), 'li:first-child'),
+        9,
+        null,
+        'tab'
+      );
+      flush();
+
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(element._focused);
+    });
+
+    test('tap on suggestion commits, does not call focus', () => {
+      focusSpy = sinon.spy(element, 'focus');
+      element._focused = true;
+      element._suggestions = [{name: 'first suggestion'}];
+      flush$0();
+      assert.isFalse(suggestionsEl().isHidden);
+      MockInteractions.tap(queryAndAssert(suggestionsEl(), 'li:first-child'));
+      flush();
+
+      assert.isFalse(focusSpy.called);
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(suggestionsEl().isHidden);
+    });
+  });
+
+  test('input-keydown event fired', () => {
+    const listener = sinon.spy();
+    element.addEventListener('input-keydown', listener);
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+    flush();
+    assert.isTrue(listener.called);
+  });
+
+  test('enter with modifier does not complete', () => {
+    const handleSpy = sinon.spy(element, '_handleKeydown');
+    const commitStub = sinon.stub(element, '_handleInputCommit');
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, 'ctrl', 'enter');
+    assert.isTrue(handleSpy.called);
+    assert.isFalse(commitStub.called);
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+    assert.isTrue(commitStub.called);
+  });
+
+  suite('warnUncommitted', () => {
+    let inputClassList: DOMTokenList;
+    setup(() => {
+      inputClassList = inputEl().classList;
+    });
+
+    test('enabled', () => {
+      element.warnUncommitted = true;
+      element.text = 'blah blah blah';
+      MockInteractions.blur(inputEl());
+      assert.isTrue(inputClassList.contains('warnUncommitted'));
+      MockInteractions.focus(inputEl());
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+
+    test('disabled', () => {
+      element.warnUncommitted = false;
+      element.text = 'blah blah blah';
+      MockInteractions.blur(inputEl());
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+
+    test('no text', () => {
+      element.warnUncommitted = true;
+      element.text = '';
+      MockInteractions.blur(inputEl());
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+  });
+});
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
index 45116aa..dae8e2e 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -49,7 +49,14 @@
   value: SuggestedReviewerInfo;
 }
 
-export class GrReviewerSuggestionsProvider {
+export interface ReviewerSuggestionsProvider {
+  init(): void;
+  getSuggestions(input: string): Promise<Suggestion[]>;
+  makeSuggestionItem(suggestion: Suggestion): SuggestionItem;
+}
+
+export class GrReviewerSuggestionsProvider
+  implements ReviewerSuggestionsProvider {
   static create(
     restApi: RestApiService,
     changeNumber: NumericChangeId,
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index fa40529..4f06f7e 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -67,6 +67,8 @@
   RelatedChangesInfo,
   FixSuggestionInfo,
   FixId,
+  GroupInfo,
+  GroupId,
 } from '../types/common';
 import {
   AccountsVisibility,
@@ -623,3 +625,9 @@
     replacements: [],
   };
 }
+
+export function createGroupInfo(id = 'id'): GroupInfo {
+  return {
+    id: id as GroupId,
+  };
+}