Merge "Minor change to gr-status API."
diff --git a/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java b/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java
index 8f51e13..2805f52 100644
--- a/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java
+++ b/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java
@@ -84,11 +84,11 @@
   }
 
   /** Event to be fired when an attention set changes */
-  private static class Event extends AbstractChangeEvent implements AttentionSetListener.Event {
+  public static class Event extends AbstractChangeEvent implements AttentionSetListener.Event {
     private final Set<Integer> added;
     private final Set<Integer> removed;
 
-    Event(
+    public Event(
         ChangeInfo change,
         AccountInfo editor,
         Set<Integer> added,
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 97e2f55..7a6ac0d 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -513,7 +513,8 @@
 
   private RevisionResource onBehalfOf(RevisionResource rev, LabelTypes labelTypes, ReviewInput in)
       throws BadRequestException, AuthException, UnprocessableEntityException,
-          PermissionBackendException, IOException, ConfigInvalidException {
+          ResourceConflictException, PermissionBackendException, IOException,
+          ConfigInvalidException {
     logger.atFine().log("request is executed on behalf of %s", in.onBehalfOf);
 
     if (in.labels == null || in.labels.isEmpty()) {
@@ -571,7 +572,7 @@
     try {
       permissionBackend.user(reviewer).change(rev.getNotes()).check(ChangePermission.READ);
     } catch (AuthException e) {
-      throw new UnprocessableEntityException(
+      throw new ResourceConflictException(
           String.format("on_behalf_of account %s cannot see change", reviewer.getAccountId()), e);
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 859ccbd..180ec45 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -4778,6 +4778,43 @@
     assertThat(gApi.changes().query(changeId).get().get(0).mergeable).isNull();
   }
 
+  @Test
+  public void ccUserThatCannotSeeTheChange() throws Exception {
+    // Create a project that is only visible to admin users.
+    Project.NameKey project = projectOperations.newProject().create();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(adminGroupUuid()))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    // Create a change
+    TestRepository<?> adminTestRepo = cloneProject(project, admin);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), adminTestRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    // Check that the change is not visible to user.
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id(r.getChangeId()).get());
+
+    // Add user as a CC.
+    requestScopeOperations.setApiUser(admin.id());
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = CC;
+    reviewerInput.reviewer = user.id().toString();
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput);
+
+    // Check that user was not added as a CC since they cannot see the change. Note,
+    // ChangeInfo#reviewers is a map that also contains CCs (if any are present).
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
+
+    // Check that the change is still not visible to user.
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id(r.getChangeId()).get());
+  }
+
   private PushOneCommit.Result createWorkInProgressChange() throws Exception {
     return pushTo("refs/for/master%wip");
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index 4da4da4..eb827c0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -62,6 +62,7 @@
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -313,8 +314,8 @@
     in.onBehalfOf = user.id().toString();
     in.label("Code-Review", 1);
 
-    UnprocessableEntityException thrown =
-        assertThrows(UnprocessableEntityException.class, () -> revision.review(in));
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> revision.review(in));
     assertThat(thrown)
         .hasMessageThat()
         .contains("on_behalf_of account " + user.id() + " cannot see change");
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
index a5da590..4b62710 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -5,6 +5,7 @@
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 import '../gr-permission/gr-permission';
 import {
   AccessPermissions,
@@ -32,7 +33,6 @@
 import {customElement, property, query, state} from 'lit/decorators';
 import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
-import {iconStyles} from '../../../styles/gr-icon-styles';
 
 /**
  * Fired when the section has been modified or removed.
@@ -103,7 +103,6 @@
     return [
       formStyles,
       fontStyles,
-      iconStyles,
       sharedStyles,
       css`
         :host {
@@ -180,7 +179,7 @@
                 class=${this.section?.id === GLOBAL_NAME ? 'global' : ''}
                 @click=${this.editReference}
               >
-                <span class="material-icon" id="icon">edit</span>
+                <gr-icon id="icon" icon="edit"></gr-icon>
               </gr-button>
             </div>
             <iron-input
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
index c59ef0a..1048dde 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
@@ -84,7 +84,7 @@
                   role="button"
                   tabindex="0"
                 >
-                  <span class="material-icon" id="icon"> edit </span>
+                  <gr-icon icon="edit" id="icon"></gr-icon>
                 </gr-button>
               </div>
               <iron-input class="editRefInput">
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index bd6773e..270d9be 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-page-nav/gr-page-nav';
 import '../gr-admin-group-list/gr-admin-group-list';
 import '../gr-group/gr-group';
@@ -50,7 +51,6 @@
 import {customElement, property, state} from 'lit/decorators';
 import {ifDefined} from 'lit/directives/if-defined';
 import {ValueChangedEvent} from '../../../types/events';
-import {iconStyles} from '../../../styles/gr-icon-styles';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -125,7 +125,6 @@
 
   static override get styles() {
     return [
-      iconStyles,
       sharedStyles,
       menuPageStyles,
       pageNavStyles,
@@ -134,7 +133,7 @@
           /* Same as dropdown trigger so chevron spacing is consistent. */
           padding: 5px 4px;
         }
-        .material-icon {
+        gr-icon {
           margin: 0 var(--spacing-xs);
         }
         .breadcrumb {
@@ -240,7 +239,7 @@
       <section class="mainHeader">
         <span class="breadcrumb">
           <span class="breadcrumbText">${this.breadcrumbParentName}</span>
-          <span class="material-icon">chevron_right</span>
+          <gr-icon icon="chevron_right"></gr-icon>
         </span>
         <gr-dropdown-list
           id="pageSelect"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
index 35ab0da..9aa0598 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
@@ -32,6 +32,7 @@
 import {ProgressStatus} from '../../../constants/constants';
 import {fireAlert, fireReload} from '../../../utils/event-util';
 import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-icon/gr-icon';
 import '../../change/gr-label-score-row/gr-label-score-row';
 import {getOverallStatus} from '../../../utils/bulk-flow-util';
 import {allSettled} from '../../../utils/async-util';
@@ -39,7 +40,6 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {Interaction} from '../../../constants/reporting';
-import {iconStyles} from '../../../styles/gr-icon-styles';
 
 @customElement('gr-change-list-bulk-vote-flow')
 export class GrChangeListBulkVoteFlow extends LitElement {
@@ -62,7 +62,6 @@
   static override get styles() {
     return [
       fontStyles,
-      iconStyles,
       css`
         gr-dialog {
           width: 840px;
@@ -97,20 +96,20 @@
           background-color: var(--error-background);
           margin-top: var(--spacing-l);
         }
-        .code-review-message-container .material-icon,
-        .error-container .material-icon {
+        .code-review-message-container gr-icon,
+        .error-container gr-icon {
           padding: 10px var(--spacing-xl);
         }
-        .error-container .material-icon {
+        .error-container gr-icon {
           color: var(--error-foreground);
         }
-        .code-review-message-container .material-icon {
+        .code-review-message-container gr-icon {
           color: var(--selected-foreground);
         }
-        .error-container span,
-        .code-review-message-container span {
+        .error-container .error-text,
+        .code-review-message-container .warning-text {
           position: relative;
-          top: 1px;
+          top: 10px;
         }
         .code-review-message-container {
           display: table-caption;
@@ -196,10 +195,8 @@
       <div class="code-review-message-container">
         <div class="code-review-message-layout-container">
           <div>
-            <span class="material-icon" aria-label="Information" role="img"
-              >info</span
-            >
-            <span>
+            <gr-icon icon="info" aria-label="Information" role="img"></gr-icon>
+            <span class="warning-text">
               Code Review vote is only available on the individual change page
             </span>
           </div>
@@ -241,10 +238,8 @@
     }
     return html`
       <div class="error-container">
-        <span class="material-icon filled" role="img" aria-label="Error"
-          >error</span
-        >
-        <span>
+        <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
+        <span class="error-text">
           <!-- prettier-ignore -->
           Failed to vote on ${pluralize(
             Array.from(this.progressByChange.values()).filter(
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
index 18284a7..6f9e4a0 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
@@ -160,8 +160,8 @@
               <div class="code-review-message-container">
                 <div class="code-review-message-layout-container">
                 <div>
-                  <span class="material-icon" aria-label="Information" role="img">info</span>
-                  <span>
+                  <gr-icon icon="info" aria-label="Information" role="img"></gr-icon>
+                  <span class="warning-text">
                     Code Review vote is only available on the individual change page
                   </span>
                 </div>
@@ -248,8 +248,8 @@
               <div class="code-review-message-container">
                 <div class="code-review-message-layout-container">
                 <div>
-                  <span class="material-icon" aria-label="Information" role="img">info</span>
-                  <span>
+                  <gr-icon icon="info" aria-label="Information" role="img"></gr-icon>
+                  <span class="warning-text">
                     Code Review vote is only available on the individual change page
                   </span>
                 </div>
@@ -280,8 +280,8 @@
               </gr-label-score-row>
             </div>
             <div class="error-container">
-              <span class="material-icon filled" role="img" aria-label="Error">error</span>
-              <span> Failed to vote on 1 change </span>
+              <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
+              <span class="error-text"> Failed to vote on 1 change </span>
             </div>
           </div>
         </gr-dialog>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
index 20899c2..b19094f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
@@ -5,6 +5,7 @@
  */
 import '../../change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard';
 import '../../shared/gr-change-status/gr-change-status';
+import '../../shared/gr-icon/gr-icon';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
 import {
@@ -29,7 +30,6 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {ifDefined} from 'lit/directives/if-defined';
 import {capitalizeFirstLetter} from '../../../utils/string-util';
-import {iconStyles} from '../../../styles/gr-icon-styles';
 
 @customElement('gr-change-list-column-requirement')
 export class GrChangeListColumnRequirement extends LitElement {
@@ -41,7 +41,6 @@
 
   static override get styles() {
     return [
-      iconStyles,
       submitRequirementsStyles,
       sharedStyles,
       css`
@@ -139,11 +138,13 @@
   }
 
   private renderStatusIcon(status: SubmitRequirementStatus) {
-    const icon = iconForStatus(status ?? SubmitRequirementStatus.ERROR);
+    const icon = iconForStatus(status);
     return html`
-      <span class="material-icon ${icon.icon} ${icon.filled ? 'filled' : ''}"
-        >${icon.icon}</span
-      >
+      <gr-icon
+        class=${icon.icon}
+        icon=${icon.icon}
+        ?filled=${icon.filled}
+      ></gr-icon>
     `;
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
index 067cce8..1ca21a4 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
@@ -57,7 +57,7 @@
     expect(element).shadowDom.to.equal(
       /* HTML */
       ` <div class="container" title="Satisfied">
-        <span class="material-icon filled check_circle">check_circle</span>
+        <gr-icon class="check_circle" filled icon="check_circle"></gr-icon>
       </div>`
     );
   });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
index 4ac7581..eec67b9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
@@ -5,6 +5,7 @@
  */
 import '../../change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard';
 import '../../shared/gr-change-status/gr-change-status';
+import '../../shared/gr-icon/gr-icon';
 import {LitElement, css, html, TemplateResult} from 'lit';
 import {customElement, property} from 'lit/decorators';
 import {ChangeInfo, SubmitRequirementStatus} from '../../../api/rest-api';
@@ -16,7 +17,6 @@
 } from '../../../utils/label-util';
 import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
 import {pluralize} from '../../../utils/string-util';
-import {iconStyles} from '../../../styles/gr-icon-styles';
 
 @customElement('gr-change-list-column-requirements-summary')
 export class GrChangeListColumnRequirementsSummary extends LitElement {
@@ -25,17 +25,16 @@
 
   static override get styles() {
     return [
-      iconStyles,
       submitRequirementsStyles,
       css`
-        .material-icon {
+        gr-icon {
           font-size: var(--line-height-normal, 20px);
         }
-        .material-icon.block,
-        .material-icon.check_circle {
+        gr-icon.block,
+        gr-icon.check_circle {
           margin-right: var(--spacing-xs);
         }
-        .material-icon.commentIcon {
+        gr-icon.commentIcon {
           color: var(--deemphasized-text-color);
           margin-left: var(--spacing-s);
         }
@@ -117,10 +116,12 @@
     return html`<span class=${icon.icon} role="button" tabindex="0">
       <gr-submit-requirement-dashboard-hovercard .change=${this.change}>
       </gr-submit-requirement-dashboard-hovercard>
-      <span
-        class="material-icon ${icon.icon} ${icon.filled ? 'filled' : ''}"
+      <gr-icon
+        class=${icon.icon}
+        icon=${icon.icon}
+        ?filled=${icon.filled}
         role="img"
-        >${icon.icon}</span
+      ></gr-icon
       >${aggregation}</span
     >`;
   }
@@ -131,14 +132,15 @@
 
   renderCommentIcon() {
     if (!this.change?.unresolved_comment_count) return;
-    return html`<span
-      class="commentIcon material-icon filled"
+    return html`<gr-icon
+      class="commentIcon"
+      icon="mode_comment"
+      filled
       .title=${pluralize(
         this.change?.unresolved_comment_count,
         'unresolved comment'
       )}
-      >mode_comment</span
-    >`;
+    ></gr-icon>`;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
index bcdf143..eecb009 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
@@ -64,7 +64,7 @@
     >
       <gr-submit-requirement-dashboard-hovercard>
       </gr-submit-requirement-dashboard-hovercard>
-      <span class="material-icon block" role="img">block</span>
+      <gr-icon class="block" role="img" icon="block"></gr-icon>
       <span class="unsatisfied">1 missing</span>
     </span>`);
   });
@@ -85,13 +85,14 @@
       >
         <gr-submit-requirement-dashboard-hovercard>
         </gr-submit-requirement-dashboard-hovercard>
-        <span class="material-icon block" role="img">block</span>
+        <gr-icon class="block" role="img" icon="block"></gr-icon>
         <span class="unsatisfied">1 missing</span>
       </span>
-      <span
-        class="commentIcon material-icon filled"
+      <gr-icon
+        class="commentIcon"
+        filled
+        icon="mode_comment"
         title="5 unresolved comments"
-        >mode_comment</span
-      >`);
+      ></gr-icon>`);
   });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
index 6164355..688e78d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
@@ -10,6 +10,7 @@
 import {ChangeInfo, Hashtag} from '../../../types/common';
 import {subscribe} from '../../lit/subscription-controller';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '@polymer/iron-dropdown/iron-dropdown';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
@@ -26,7 +27,6 @@
 import {fireAlert} from '../../../utils/event-util';
 import {pluralize} from '../../../utils/string-util';
 import {Interaction} from '../../../constants/reporting';
-import {iconStyles} from '../../../styles/gr-icon-styles';
 
 @customElement('gr-change-list-hashtag-flow')
 export class GrChangeListHashtagFlow extends LitElement {
@@ -57,7 +57,6 @@
 
   static override get styles() {
     return [
-      iconStyles,
       spinnerStyles,
       css`
         iron-dropdown {
@@ -121,7 +120,7 @@
         .error {
           color: var(--deemphasized-text-color);
         }
-        .material-icon {
+        gr-icon {
           color: var(--error-color);
           /* Center with text by aligning it to the top and then pushing it down
              to match the text */
@@ -251,7 +250,7 @@
         `;
       case ProgressStatus.FAILED:
         return html`
-          <span class="material-icon filled">error</span>
+          <gr-icon icon="error" filled></gr-icon>
           <div class="error">${this.errorText}</div>
         `;
       default:
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 013dd9e..8910e27 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -7,6 +7,7 @@
 import '../../shared/gr-change-star/gr-change-star';
 import '../../shared/gr-change-status/gr-change-status';
 import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-limited-text/gr-limited-text';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
@@ -41,7 +42,6 @@
 import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
 import {resolve} from '../../../models/dependency';
 import {subscribe} from '../../lit/subscription-controller';
-import {iconStyles} from '../../../styles/gr-icon-styles';
 
 enum ChangeSize {
   XS = 10,
@@ -153,7 +153,6 @@
   static override get styles() {
     return [
       changeListStyles,
-      iconStyles,
       sharedStyles,
       submitRequirementsStyles,
       css`
@@ -266,7 +265,7 @@
         .cell.label {
           font-weight: var(--font-weight-normal);
         }
-        .cell.label .material-icon {
+        .cell.label gr-icon {
           vertical-align: top;
         }
         /* Requirement child needs whole area */
@@ -463,7 +462,7 @@
     return html`
       <td class="cell comments">
         ${this.change?.unresolved_comment_count
-          ? html`<span class="material-icon filled">mode_comment</span>`
+          ? html`<gr-icon icon="mode_comment" filled></gr-icon>`
           : ''}
         <span
           >${this.computeComments(this.change?.unresolved_comment_count)}</span
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
index f48f512..a2a3d45 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -21,6 +21,7 @@
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {getAppContext} from '../../../services/app-context';
 import {
@@ -140,7 +141,7 @@
         .warning + .warning {
           margin-top: var(--spacing-s);
         }
-        .material-icon {
+        gr-icon {
           color: var(--orange-800);
           font-size: 18px;
         }
@@ -309,9 +310,7 @@
     }
     return html`
       <div class="error">
-        <span class="material-icon filled" role="img" aria-label="Error"
-          >error</span
-        >
+        <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
         Failed to add ${listForSentence(failedAccounts)} to changes.
       </div>
     `;
@@ -335,9 +334,12 @@
       updatedReviewerState === ReviewerState.CC ? 'CC' : 'reviewer';
     return html`
       <div class="warning">
-        <span class="material-icon filled" role="img" aria-label="Warning"
-          >warning</span
-        >
+        <gr-icon
+          icon="warning"
+          filled
+          role="img"
+          aria-label="Warning"
+        ></gr-icon>
         ${listForSentence(overwrittenNames)} ${pluralizedVerb} ${currentLabel}
         on some selected changes and will be moved to ${updatedLabel} on all
         changes.
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
index 029dd6f..5a4a90c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
@@ -683,15 +683,15 @@
                   </gr-overlay>
                 </div>
                 <div class="warning">
-                  <span class="material-icon filled" role="img"
-                  aria-label="Warning">warning</span>
+                  <gr-icon icon="warning" filled role="img" aria-label="Warning"
+                  ></gr-icon>
                   User-1 is a reviewer
         on some selected changes and will be moved to CC on all
         changes.
                 </div>
                 <div class="warning">
-                  <span class="material-icon filled"role="img"
-                  aria-label="Warning">warning</span>
+                  <gr-icon icon="warning" filled role="img" aria-label="Warning"
+                  ></gr-icon>
                   User-4 is a CC
         on some selected changes and will be moved to reviewer on all
         changes.
@@ -804,7 +804,7 @@
                   </gr-overlay>
                 </div>
                 <div class="error">
-                  <span class="material-icon filled" role="img" aria-label="Error">error</span>
+                  <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
                   Failed to add User-0, User-2, Group 0, and User-3 to changes.
                 </div>
               </div>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
index e1d6ae9..53d59f5 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
@@ -10,6 +10,7 @@
 import {ChangeInfo, TopicName} from '../../../types/common';
 import {subscribe} from '../../lit/subscription-controller';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '@polymer/iron-dropdown/iron-dropdown';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
@@ -27,7 +28,6 @@
 import {fireAlert} from '../../../utils/event-util';
 import {pluralize} from '../../../utils/string-util';
 import {Interaction} from '../../../constants/reporting';
-import {iconStyles} from '../../../styles/gr-icon-styles';
 
 @customElement('gr-change-list-topic-flow')
 export class GrChangeListTopicFlow extends LitElement {
@@ -58,7 +58,6 @@
 
   static override get styles() {
     return [
-      iconStyles,
       spinnerStyles,
       css`
         iron-dropdown {
@@ -121,7 +120,7 @@
         .error {
           color: var(--deemphasized-text-color);
         }
-        .material-icon {
+        gr-icon {
           color: var(--error-color);
           /* Center with text by aligning it to the top and then pushing it down
              to match the text */
@@ -264,7 +263,7 @@
         `;
       case ProgressStatus.FAILED:
         return html`
-          <span class="material-icon filled">error</span>
+          <gr-icon icon="error" filled></gr-icon>
           <div class="error">${this.errorText}</div>
         `;
       default:
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 170525d..de9c211 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -131,7 +131,7 @@
           margin-right: 20px;
           color: var(--deemphasized-text-color);
         }
-        .material-icon {
+        gr-icon {
           font-size: 1.85rem;
           margin-left: 16px;
         }
@@ -210,7 +210,7 @@
 
     return html`
       <a id="prevArrow" href=${this.computeNavLink(-1)}>
-        <span class="material-icon" aria-label="Older">chevron_left</span>
+        <gr-icon icon="chevron_left" aria-label="Older"></gr-icon>
       </a>
     `;
   }
@@ -226,7 +226,7 @@
 
     return html`
       <a id="nextArrow" href=${this.computeNavLink(1)}>
-        <span class="material-icon" aria-label="Newer">chevron_right</span>
+        <gr-icon icon="chevron_right" aria-label="Newer"></gr-icon>
       </a>
     `;
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
index 1ebe328..14fd2e8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
@@ -61,7 +61,7 @@
         <nav>
           Page
           <a href="" id="prevArrow">
-            <span class="material-icon" aria-label="Older">chevron_left</span>
+            <gr-icon icon="chevron_left" aria-label="Older"></gr-icon>
           </a>
         </nav>
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 49526d2..50c51c2 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -985,7 +985,7 @@
           showPrependedDynamicColumns
         ),
       initialCount: this.fileListIncrement,
-      targetFrameRate: 30,
+      targetFrameRate: 1,
     });
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index a3aa8e6..8262b09 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -81,7 +81,7 @@
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
       stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
-      stub('gr-date-formatter', '_loadTimeFormat').callsFake(() =>
+      stub('gr-date-formatter', 'loadTimeFormat').callsFake(() =>
         Promise.resolve()
       );
       stub('gr-diff-host', 'reload').callsFake(() => Promise.resolve());
@@ -1997,7 +1997,7 @@
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-      stub('gr-date-formatter', '_loadTimeFormat').callsFake(() =>
+      stub('gr-date-formatter', 'loadTimeFormat').callsFake(() =>
         Promise.resolve()
       );
       stubRestApi('getDiff').callsFake(() => Promise.resolve(createDiff()));
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 2daf768..38b6eea 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -10,6 +10,7 @@
 import '../../shared/gr-account-chip/gr-account-chip';
 import '../../shared/gr-textarea/gr-textarea';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-formatted-text/gr-formatted-text';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-account-list/gr-account-list';
@@ -106,7 +107,6 @@
 import {customElement, property, state, query} from 'lit/decorators';
 import {subscribe} from '../../lit/subscription-controller';
 import {configModelToken} from '../../../models/config/config-model';
-import {iconStyles} from '../../../styles/gr-icon-styles';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -341,7 +341,6 @@
 
   static override styles = [
     sharedStyles,
-    iconStyles,
     css`
       :host {
         background-color: var(--dialog-background-color);
@@ -491,7 +490,7 @@
         vertical-align: top;
         --gr-button-padding: 0px 4px;
       }
-      .attention .edit-attention-button .material-icon {
+      .attention .edit-attention-button gr-icon {
         color: inherit;
       }
       .attention a,
@@ -553,7 +552,7 @@
         margin-top: var(--spacing-m);
         background-color: var(--assignee-highlight-color);
       }
-      .attentionTip div iron-icon {
+      .attentionTip div gr-icon {
         margin-right: var(--spacing-s);
       }
       .patchsetLevelContainer {
@@ -969,7 +968,7 @@
                 role="button"
                 tabindex="0"
               >
-                <span class="material-icon filled">edit</span>
+                <gr-icon icon="edit" filled></gr-icon>
                 Modify
               </gr-button>
             </gr-tooltip-content>
@@ -979,7 +978,7 @@
               href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
               target="_blank"
             >
-              <span class="material-icon" title="read documentation">help</span>
+              <gr-icon icon="help" title="read documentation"></gr-icon>
             </a>
           </div>
         </div>
@@ -1001,7 +1000,7 @@
               href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
               target="_blank"
             >
-              <span class="material-icon" title="read documentation">help</span>
+              <gr-icon icon="help" title="read documentation"></gr-icon>
             </a>
           </div>
         </div>
@@ -1090,7 +1089,7 @@
           this.computeShowAttentionTip(),
           () => html`
             <div class="attentionTip">
-              <span class="material-icon pointer">lightbulb</span>
+              <gr-icon icon="lightbulb"></gr-icon>
               Please be mindful of requiring attention from too many users.
             </div>
           `
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index 70f708d..b62a430 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -281,7 +281,7 @@
                       role="button"
                       tabindex="0"
                     >
-                      <span class="material-icon filled">edit</span>
+                      <gr-icon icon="edit" filled></gr-icon>
                       Modify
                     </gr-button>
                   </gr-tooltip-content>
@@ -291,9 +291,7 @@
                     href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
                     target="_blank"
                   >
-                    <span class="material-icon" title="read documentation"
-                      >help</span
-                    >
+                    <gr-icon icon="help" title="read documentation"></gr-icon>
                   </a>
                 </div>
               </div>
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
index 354b7d3..ef58f94 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-label-info/gr-label-info';
 import {customElement, property} from 'lit/decorators';
 import {
@@ -32,7 +33,6 @@
 import {CURRENT} from '../../../utils/patch-set-util';
 import {fireReload} from '../../../utils/event-util';
 import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
-import {iconStyles} from '../../../styles/gr-icon-styles';
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
 const base = HovercardMixin(LitElement);
@@ -59,7 +59,6 @@
   static override get styles() {
     return [
       fontStyles,
-      iconStyles,
       submitRequirementsStyles,
       base.styles || [],
       css`
@@ -98,7 +97,7 @@
         div.sectionIcon {
           flex: 0 0 30px;
         }
-        div.sectionIcon .material-icon {
+        div.sectionIcon gr-icon {
           position: relative;
         }
         .section.condition > .sectionContent {
@@ -112,7 +111,7 @@
         .expression {
           color: var(--gray-foreground);
         }
-        .button .material-icon {
+        .button gr-icon {
           color: inherit;
         }
         div.button {
@@ -137,7 +136,7 @@
       </div>
       <div class="section">
         <div class="sectionIcon">
-          <span class="small material-icon">info</span>
+          <gr-icon class="small" icon="info"></gr-icon>
         </div>
         <div class="sectionContent">
           <div class="row">
@@ -154,12 +153,13 @@
 
   private renderStatus(requirement: SubmitRequirementResultInfo) {
     const icon = iconForRequirement(requirement);
-    return html`<span
-      class="material-icon ${icon.icon} ${icon.filled ? 'filled' : ''}"
+    return html`<gr-icon
+      class=${icon.icon}
+      icon=${icon.icon}
+      ?filled=${icon.filled}
       role="img"
       aria-label=${requirement.status.toLowerCase()}
-      >${icon.icon}</span
-    >`;
+    ></gr-icon>`;
   }
 
   private renderDescription() {
@@ -175,7 +175,7 @@
     if (!description) return;
     return html`<div class="section description">
       <div class="sectionIcon">
-        <span class="material-icon">description</span>
+        <gr-icon icon="description"></gr-icon>
       </div>
       <div class="sectionContent">
         <gr-formatted-text
@@ -238,7 +238,7 @@
         @click=${(_: MouseEvent) => this.toggleConditionsVisibility()}
       >
         ${buttonText}
-        <span class="material-icon">${icon}</span>
+        <gr-icon .icon=${icon}></gr-icon>
       </gr-button>
     </div>`;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
index 8b7fec3..1a44caa 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
@@ -40,12 +40,14 @@
       <div id="container" role="tooltip" tabindex="-1">
         <div class="section">
           <div class="sectionIcon">
-            <span
+            <gr-icon
               aria-label="satisfied"
               role="img"
-              class="material-icon filled check_circle"
-              >check_circle
-            </span>
+              class="check_circle"
+              filled
+              icon="check_circle"
+            >
+            </gr-icon>
           </div>
           <div class="sectionContent">
             <h3 class="heading-3 name">
@@ -55,7 +57,7 @@
         </div>
         <div class="section">
           <div class="sectionIcon">
-            <span class="small material-icon">info</span>
+            <gr-icon class="small" icon="info"></gr-icon>
           </div>
           <div class="sectionContent">
             <div class="row">
@@ -73,7 +75,7 @@
             tabindex="0"
           >
             View conditions
-            <span class="material-icon">expand_more</span>
+            <gr-icon icon="expand_more"></gr-icon>
           </gr-button>
         </div>
       </div>
@@ -88,12 +90,14 @@
       <div id="container" role="tooltip" tabindex="-1">
         <div class="section">
           <div class="sectionIcon">
-            <span
+            <gr-icon
               aria-label="satisfied"
               role="img"
-              class="material-icon filled check_circle"
-              >check_circle
-            </span>
+              class="check_circle"
+              filled
+              icon="check_circle"
+            >
+            </gr-icon>
           </div>
           <div class="sectionContent">
             <h3 class="heading-3 name">
@@ -103,7 +107,7 @@
         </div>
         <div class="section">
           <div class="sectionIcon">
-            <span class="small material-icon">info</span>
+            <gr-icon class="small" icon="info"></gr-icon>
           </div>
           <div class="sectionContent">
             <div class="row">
@@ -121,7 +125,7 @@
             tabindex="0"
           >
             Hide conditions
-            <span class="material-icon">expand_less</span>
+            <gr-icon icon="expand_less"></gr-icon>
           </gr-button>
         </div>
         <div class="section condition">
@@ -168,12 +172,13 @@
       <div id="container" role="tooltip" tabindex="-1">
         <div class="section">
           <div class="sectionIcon">
-            <span
+            <gr-icon
               aria-label="satisfied"
               role="img"
-              class="material-icon filled check_circle"
-              >check_circle
-            </span>
+              class="check_circle"
+              filled
+              icon="check_circle"
+            ></gr-icon>
           </div>
           <div class="sectionContent">
             <h3 class="heading-3 name">
@@ -183,7 +188,7 @@
         </div>
         <div class="section">
           <div class="sectionIcon">
-            <span class="small material-icon">info</span>
+            <gr-icon class="small" icon="info"></gr-icon>
           </div>
           <div class="sectionContent">
             <div class="row">
@@ -202,7 +207,7 @@
         </div>
         <div class="section description">
           <div class="sectionIcon">
-            <span class="material-icon">description</span>
+            <gr-icon icon="description"></gr-icon>
           </div>
           <div class="sectionContent">
             <gr-formatted-text notrailingmargin=""></gr-formatted-text>
@@ -217,7 +222,7 @@
             tabindex="0"
           >
             View conditions
-            <span class="material-icon">expand_more</span>
+            <gr-icon icon="expand_more"></gr-icon>
           </gr-button>
         </div>
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index eac9e6f..63566c1 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-label-info/gr-label-info';
+import '../../shared/gr-icon/gr-icon';
 import '../gr-submit-requirement-hovercard/gr-submit-requirement-hovercard';
 import '../gr-trigger-vote/gr-trigger-vote';
 import '../gr-change-summary/gr-change-summary';
@@ -43,7 +44,6 @@
 import {checksModelToken} from '../../../models/checks/checks-model';
 import {join} from 'lit/directives/join';
 import {map} from 'lit/directives/map';
-import {iconStyles} from '../../../styles/gr-icon-styles';
 
 /**
  * @attr {Boolean} suppress-title - hide titles, currently for hovercard view
@@ -71,7 +71,6 @@
   static override get styles() {
     return [
       fontStyles,
-      iconStyles,
       submitRequirementsStyles,
       css`
         :host([suppress-title]) .metadata-title {
@@ -83,7 +82,7 @@
           margin: 0 0 var(--spacing-s);
           padding-top: var(--spacing-s);
         }
-        .material-icon {
+        gr-icon {
           font-size: var(--line-height-normal, 20px);
         }
         .requirements,
@@ -244,12 +243,13 @@
 
   private renderStatus(requirement: SubmitRequirementResultInfo) {
     const icon = iconForRequirement(requirement);
-    return html`<span
-      class="material-icon ${icon.icon} ${icon.filled ? 'filled' : ''}"
+    return html`<gr-icon
+      class=${icon.icon}
+      ?filled=${icon.filled}
+      .icon=${icon.icon}
       role="img"
       aria-label=${requirement.status.toLowerCase()}
-      >${icon.icon}</span
-    >`;
+    ></gr-icon>`;
   }
 
   renderVoteCell(requirement: SubmitRequirementResultInfo) {
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
index 4d1cd54..f380159 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
@@ -78,12 +78,14 @@
         <tbody>
           <tr id="requirement-0-Verified" role="button" tabindex="0">
             <td>
-              <span
+              <gr-icon
                 aria-label="satisfied"
                 role="img"
-                class="material-icon filled check_circle"
-                >check_circle
-              </span>
+                class="check_circle"
+                filled
+                icon="check_circle"
+              >
+              </gr-icon>
             </td>
             <td class="name">
               <gr-limited-text class="name"></gr-limited-text>
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 255f60d..1608528 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -599,6 +599,7 @@
 
   static override get styles() {
     return [
+      iconStyles,
       sharedStyles,
       css`
         .links {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
index 5af3c4c..d438a73 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
@@ -160,7 +160,7 @@
                 <span
                   aria-label="Fake Bug Report 1"
                   class="material-icon filled link"
-                  >bug
+                  >bug_report
                 </span>
                 <paper-tooltip offset="5"> </paper-tooltip>
               </a>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
index 921518d27..b9d621d 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
@@ -5,17 +5,28 @@
  */
 import '../../../test/common-test-setup-karma';
 import './gr-account-dropdown';
+import {fixture, html} from '@open-wc/testing-helpers';
 import {GrAccountDropdown} from './gr-account-dropdown';
 import {AccountInfo} from '../../../types/common';
 import {createServerInfo} from '../../../test/test-data-generators';
 
-const basicFixture = fixtureFromElement('gr-account-dropdown');
-
 suite('gr-account-dropdown tests', () => {
   let element: GrAccountDropdown;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-account-dropdown></gr-account-dropdown>`);
+  });
+
+  test('renders', async () => {
+    element.account = {name: 'John Doe', email: 'john@doe.com'} as AccountInfo;
+    await element.updateComplete;
+
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-dropdown link="">
+        <span>John Doe</span>
+        <gr-avatar aria-label="Account avatar" hidden=""> </gr-avatar>
+      </gr-dropdown>
+    `);
   });
 
   test('account information', () => {
@@ -29,7 +40,7 @@
   test('test for account without a name', () => {
     element.account = {id: '0001'} as AccountInfo;
     assert.deepEqual(element.topContent, [
-      {text: 'Anonymous', bold: true},
+      {text: 'Name of user not set', bold: true},
       {text: ''},
     ]);
   });
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
index fd474e5..19db752 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
@@ -3,20 +3,34 @@
  * Copyright 2018 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import {fixture, html} from '@open-wc/testing-helpers';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import '../../../test/common-test-setup-karma';
 import {mockPromise, queryAndAssert} from '../../../test/test-utils';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrErrorDialog} from './gr-error-dialog';
-
-const basicFixture = fixtureFromElement('gr-error-dialog');
+import './gr-error-dialog';
 
 suite('gr-error-dialog tests', () => {
   let element: GrErrorDialog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-error-dialog></gr-error-dialog>`);
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-dialog
+        cancel-label=""
+        confirm-label="Dismiss"
+        confirm-on-enter=""
+        id="dialog"
+        role="dialog"
+      >
+        <div class="header" slot="header">An error occurred</div>
+        <div class="main" slot="main"></div>
+      </gr-dialog>
+    `);
   });
 
   test('dismiss tap fires event', async () => {
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index d7b1130..6487bb8 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -319,7 +319,7 @@
     this.reporting.error(new Error(`network error: ${e.detail.error.message}`));
   };
 
-  // TODO(dhruvsr): allow less priority alerts to override high priority alerts
+  // TODO(dhruvsri): allow less priority alerts to override high priority alerts
   // In some use cases we may want generic alerts to show along/over errors
   // private but used in tests
   canOverride(incoming = ErrorType.GENERIC, existing = ErrorType.GENERIC) {
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
index de23365..a4f1893 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
@@ -56,6 +56,31 @@
       });
     });
 
+    test('renders', () => {
+      expect(element).shadowDom.to.equal(/* HTML */ `
+        <gr-overlay
+          aria-hidden="true"
+          id="errorOverlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <gr-error-dialog id="errorDialog"> </gr-error-dialog>
+        </gr-overlay>
+        <gr-overlay
+          always-on-top=""
+          aria-hidden="true"
+          id="noInteractionOverlay"
+          no-cancel-on-esc-key=""
+          no-cancel-on-outside-click=""
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+        </gr-overlay>
+      `);
+    });
+
     test('does not show auth error on 403 by default', async () => {
       const showAuthErrorStub = sinon.stub(element, 'showAuthErrorAlert');
       const responseText = Promise.resolve('server says no.');
@@ -305,7 +330,7 @@
       assert.equal(fetchStub.callCount, 1);
       await flush();
 
-      // here needs two flush as there are two chanined
+      // here needs two flush as there are two chained
       // promises on server-error handler and flush only flushes one
       assert.equal(fetchStub.callCount, 2);
       await flush();
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts
index 480391a..5d12c72 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts
@@ -3,38 +3,58 @@
  * Copyright 2018 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import {fixture, html} from '@open-wc/testing-helpers';
 import '../../../test/common-test-setup-karma';
 import './gr-key-binding-display';
 import {GrKeyBindingDisplay} from './gr-key-binding-display';
 
-const basicFixture = fixtureFromElement('gr-key-binding-display');
+const x = ['x'];
+const ctrlX = ['Ctrl', 'x'];
+const shiftMetaX = ['Shift', 'Meta', 'x'];
 
 suite('gr-key-binding-display tests', () => {
   let element: GrKeyBindingDisplay;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-key-binding-display
+        .binding=${[x, ctrlX, shiftMetaX]}
+      ></gr-key-binding-display>`
+    );
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <span class="key"> x </span>
+      or
+      <span class="key modifier"> Ctrl </span>
+      <span class="key"> x </span>
+      or
+      <span class="key modifier"> Shift </span>
+      <span class="key modifier"> Meta </span>
+      <span class="key"> x </span>
+    `);
   });
 
   suite('_computeKey', () => {
     test('unmodified key', () => {
-      assert.strictEqual(element._computeKey(['x']), 'x');
+      assert.strictEqual(element._computeKey(x), 'x');
     });
 
     test('key with modifiers', () => {
-      assert.strictEqual(element._computeKey(['Ctrl', 'x']), 'x');
-      assert.strictEqual(element._computeKey(['Shift', 'Meta', 'x']), 'x');
+      assert.strictEqual(element._computeKey(ctrlX), 'x');
+      assert.strictEqual(element._computeKey(shiftMetaX), 'x');
     });
   });
 
   suite('_computeModifiers', () => {
     test('single unmodified key', () => {
-      assert.deepEqual(element._computeModifiers(['x']), []);
+      assert.deepEqual(element._computeModifiers(x), []);
     });
 
     test('key with modifiers', () => {
-      assert.deepEqual(element._computeModifiers(['Ctrl', 'x']), ['Ctrl']);
-      assert.deepEqual(element._computeModifiers(['Shift', 'Meta', 'x']), [
+      assert.deepEqual(element._computeModifiers(ctrlX), ['Ctrl']);
+      assert.deepEqual(element._computeModifiers(shiftMetaX), [
         'Shift',
         'Meta',
       ]);
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.ts
index e04c48c..bed46ef 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.ts
@@ -13,6 +13,10 @@
 
 const basicFixture = fixtureFromElement('gr-keyboard-shortcuts-dialog');
 
+const x = ['x'];
+const ctrlX = ['Ctrl', 'x'];
+const shiftMetaX = ['Shift', 'Meta', 'x'];
+
 suite('gr-keyboard-shortcuts-dialog tests', () => {
   let element: GrKeyboardShortcutsDialog;
 
@@ -26,6 +30,83 @@
     flush();
   }
 
+  test('renders left and right contents', async () => {
+    const directory = new Map([
+      [
+        ShortcutSection.NAVIGATION,
+        [{binding: [x, ctrlX], text: 'navigation shortcuts'}],
+      ],
+      [
+        ShortcutSection.ACTIONS,
+        [{binding: [shiftMetaX], text: 'navigation shortcuts'}],
+      ],
+    ]);
+    update(directory);
+    await element.updateComplete;
+
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <header>
+        <h3 class="heading-2">Keyboard shortcuts</h3>
+        <gr-button aria-disabled="false" link="" role="button" tabindex="0">
+          Close
+        </gr-button>
+      </header>
+      <main>
+        <div class="column">
+          <table>
+            <caption class="heading-3">
+              Navigation
+            </caption>
+            <thead>
+              <tr>
+                <th>
+                  <strong> Action </strong>
+                </th>
+                <th>
+                  <strong> Key </strong>
+                </th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td>navigation shortcuts</td>
+                <td>
+                  <gr-key-binding-display> </gr-key-binding-display>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+        <div class="column">
+          <table>
+            <caption class="heading-3">
+              Actions
+            </caption>
+            <thead>
+              <tr>
+                <th>
+                  <strong> Action </strong>
+                </th>
+                <th>
+                  <strong> Key </strong>
+                </th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td>navigation shortcuts</td>
+                <td>
+                  <gr-key-binding-display> </gr-key-binding-display>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </main>
+      <footer></footer>
+    `);
+  });
+
   suite('left and right contents', () => {
     test('empty dialog', () => {
       assert.isEmpty(element.left);
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
index b618a76..8134101 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -7,7 +7,6 @@
 import {map, distinctUntilChanged} from 'rxjs/operators';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../shared/gr-dropdown/gr-dropdown';
-import '../../shared/gr-icons/gr-icons';
 import '../gr-account-dropdown/gr-account-dropdown';
 import '../gr-smart-search/gr-smart-search';
 import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util';
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
index 70034aa..6da1c2c 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
@@ -28,6 +28,66 @@
     await element.updateComplete;
   });
 
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <nav>
+        <a class="bigTitle" href="//localhost:9876/">
+          <gr-endpoint-decorator name="header-title">
+            <span class="titleText"> </span>
+          </gr-endpoint-decorator>
+        </a>
+        <ul class="links">
+          <li>
+            <gr-dropdown down-arrow="" horizontal-align="left" link="">
+              <span class="linksTitle" id="Changes"> Changes </span>
+            </gr-dropdown>
+          </li>
+          <li>
+            <gr-dropdown down-arrow="" horizontal-align="left" link="">
+              <span class="linksTitle" id="Browse"> Browse </span>
+            </gr-dropdown>
+          </li>
+        </ul>
+        <div class="rightItems">
+          <gr-endpoint-decorator
+            class="hideOnMobile"
+            name="header-small-banner"
+          >
+          </gr-endpoint-decorator>
+          <gr-smart-search id="search" label="Search for changes">
+          </gr-smart-search>
+          <gr-endpoint-decorator
+            class="hideOnMobile"
+            name="header-browse-source"
+          >
+          </gr-endpoint-decorator>
+          <gr-endpoint-decorator class="feedbackButton" name="header-feedback">
+          </gr-endpoint-decorator>
+        </div>
+        <div class="accountContainer" id="accountContainer">
+          <span
+            aria-label="Hide Searchbar"
+            class="material-icon"
+            id="mobileSearch"
+            role="button"
+          >
+            search
+          </span>
+          <a class="loginButton" href="/login"> Sign in </a>
+          <a
+            aria-label="Settings"
+            class="settingsButton"
+            href="/settings/"
+            role="button"
+            title="Settings"
+          >
+            <span class="filled material-icon"> settings </span>
+          </a>
+        </div>
+      </nav>
+    `);
+  });
+
   test('link visibility', async () => {
     element.loading = true;
     await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
index db46c22..bde1ec7 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
@@ -28,6 +28,30 @@
     await element.updateComplete;
   });
 
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <form>
+        <gr-autocomplete
+          allow-non-suggested-values=""
+          id="searchInput"
+          multi=""
+          show-search-icon=""
+          tab-complete=""
+        >
+          <a
+            class="help"
+            href="https://gerrit-review.googlesource.com/documentation/user-search.html"
+            slot="suffix"
+            tabindex="-1"
+            target="_blank"
+          >
+            <span class="material-icon" title="read documentation"> help </span>
+          </a>
+        </gr-autocomplete>
+      </form>
+    `);
+  });
+
   test('value is propagated to inputVal', async () => {
     element.value = 'foo';
     await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
index 4eca296..f269200 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
@@ -19,6 +19,12 @@
     await element.updateComplete;
   });
 
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-search-bar id="search"> </gr-search-bar>
+    `);
+  });
+
   test('Autocompletes accounts', () => {
     stubRestApi('getSuggestedAccounts').callsFake(() =>
       Promise.resolve([
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 3f70197..5296f13 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -5,6 +5,7 @@
  */
 import '../../../styles/shared-styles';
 import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../../embed/diff/gr-diff/gr-diff';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
@@ -30,7 +31,6 @@
 import {customElement, property, query, state} from 'lit/decorators';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subscribe} from '../../lit/subscription-controller';
-import {iconStyles} from '../../../styles/gr-icon-styles';
 
 interface FilePreview {
   filepath: string;
@@ -107,7 +107,6 @@
   }
 
   static override styles = [
-    iconStyles,
     sharedStyles,
     css`
       gr-diff {
@@ -190,14 +189,14 @@
           @click=${this.onPrevFixClick}
           ?disabled=${id === 0}
         >
-          <span class="material-icon">chevron_left</span>
+          <gr-icon icon="chevron_left"></gr-icon>
         </gr-button>
         <gr-button
           id="nextFix"
           @click=${this.onNextFixClick}
           ?disabled=${id === fixCount - 1}
         >
-          <span class="material-icon">chevron_right</span>
+          <gr-icon icon="chevron_right"></gr-icon>
         </gr-button>
       </div>
     `;
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index af5df9b..e29731b 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -213,7 +213,7 @@
                 role="button"
                 tabindex="-1"
               >
-                <span class="material-icon">chevron_left</span>
+                <gr-icon icon="chevron_left"></gr-icon>
               </gr-button>
               <gr-button
                 aria-disabled="false"
@@ -221,7 +221,7 @@
                 role="button"
                 tabindex="0"
               >
-                <span class="material-icon">chevron_right</span>
+                <gr-icon icon="chevron_right"></gr-icon>
               </gr-button>
             </div>
           </gr-dialog>
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index bdb7db7..7570ac5 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -34,6 +34,7 @@
   [urlEncodedCommentId: string]: CommentThread;
 };
 
+// TODO: Move file out of elements/ directory
 export class ChangeComments {
   private readonly _comments: PathToCommentsInfoMap;
 
@@ -45,10 +46,6 @@
 
   private readonly _portedDrafts: PathToCommentsInfoMap;
 
-  /**
-   * Construct a change comments object, which can be data-bound to child
-   * elements of that which uses the gr-comment-api.
-   */
   constructor(
     comments?: PathToCommentsInfoMap,
     robotComments?: {[path: string]: RobotCommentInfo[]},
@@ -315,7 +312,7 @@
 
     return createCommentThreads(allComments).filter(thread => {
       // Robot comments and drafts are not ported over. A human reply to
-      // the robot comment will be ported over, thefore it's possible to
+      // the robot comment will be ported over, therefore it's possible to
       // have the root comment of the thread not be ported, hence loop over
       // entire thread
       const portedComment = portedComments.find(portedComment =>
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.ts
deleted file mode 100644
index 6f83fe9..0000000
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
deleted file mode 100644
index 66949d7..0000000
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
+++ /dev/null
@@ -1,823 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup-karma.js';
-import './gr-comment-api.js';
-import {ChangeComments} from './gr-comment-api.js';
-import {isInRevisionOfPatchRange, isInBaseOfPatchRange, isDraftThread, isUnresolved, createCommentThreads} from '../../../utils/comment-util.js';
-import {createDraft, createComment, createChangeComments, createCommentThread} from '../../../test/test-data-generators.js';
-import {CommentSide} from '../../../constants/constants.js';
-import {PARENT} from '../../../types/common.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-comment-api');
-
-suite('gr-comment-api tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  suite('_changeComment methods', () => {
-    setup(() => {
-      stubRestApi('getDiffComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-    });
-
-    suite('ported comments', () => {
-      let portedComments;
-      let changeComments;
-      const comment1 = {
-        ...createComment(),
-        unresolved: true,
-        id: '1',
-        line: 136,
-        patch_set: 2,
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 1,
-          end_character: 1,
-        },
-      };
-
-      const comment2 = {
-        ...createComment(),
-        patch_set: 2,
-        id: '2',
-        line: 5,
-      };
-
-      const comment3 = {
-        ...createComment(),
-        side: CommentSide.PARENT,
-        line: 10,
-        unresolved: true,
-      };
-
-      const comment4 = {
-        ...comment3,
-        parent: -2,
-      };
-
-      const draft1 = {
-        ...createDraft(),
-        id: 'db977012_e1f13828',
-        line: 4,
-        patch_set: 2,
-      };
-      const draft2 = {
-        ...createDraft(),
-        id: '503008e2_0ab203ee',
-        line: 11,
-        unresolved: true,
-        // slightly larger timestamp so it's sorted higher
-        updated: '2018-02-13 22:49:48.018000001',
-        patch_set: 2,
-      };
-
-      setup(() => {
-        portedComments = {
-          'karma.conf.js': [{
-            ...comment1,
-            patch_set: 4,
-            range: {
-              start_line: 136,
-              start_character: 16,
-              end_line: 136,
-              end_character: 29,
-            },
-          }],
-        };
-
-        changeComments = new ChangeComments(
-            {/* comments */
-              'karma.conf.js': [
-                // resolved comment that will not be ported over
-                comment2,
-                // original comment that will be ported over to patchset 4
-                comment1,
-              ],
-            },
-            {}/* robot comments */,
-            {}/* drafts */,
-            portedComments,
-            {}/* ported drafts */
-        );
-      });
-
-      test('threads containing ported comment are returned', () => {
-        assert.equal(changeComments.getAllThreadsForChange().length,
-            2);
-
-        const portedThreads = changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: PARENT});
-
-        assert.equal(portedThreads.length, 1);
-        // check that the location of the thread matches the ported comment
-        assert.equal(portedThreads[0].patchNum, 4);
-        assert.deepEqual(portedThreads[0].range, {
-          start_line: 136,
-          start_character: 16,
-          end_line: 136,
-          end_character: 29,
-        });
-
-        // thread ported over if comparing patchset 1 vs patchset 4
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 1}
-        ).length, 1);
-
-        // verify ported thread is not returned if original thread will be
-        // shown
-        // original thread attached to right side
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 2, basePatchNum: PARENT}
-        ).length, 0);
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 2, basePatchNum: 1}
-        ).length, 0);
-
-        // original thread attached to left side
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 3, basePatchNum: 2}
-        ).length, 0);
-      });
-
-      test('threads without any ported comment are filtered out', () => {
-        changeComments = new ChangeComments(
-            {/* comments */
-              // comment that is not ported over
-              'karma.conf.js': [comment2],
-            },
-            {}/* robot comments */,
-            {/* drafts */
-              'karma.conf.js': [draft2],
-            },
-            // comment1 that is ported over but does not have any thread
-            // that has a comment that matches it
-            portedComments,
-            {}/* ported drafts */
-        );
-
-        assert.equal(createCommentThreads(changeComments
-            .getAllCommentsForPath('karma.conf.js')).length, 1);
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: PARENT}
-        ).length, 0);
-      });
-
-      test('comments with side=PARENT are ported over', () => {
-        changeComments = new ChangeComments(
-            {/* comments */
-              // comment left on Base
-              'karma.conf.js': [comment3],
-            },
-            {}/* robot comments */,
-            {/* drafts */
-              'karma.conf.js': [draft2],
-            },
-            {/* ported comments */
-              'karma.conf.js': [{
-                ...comment3,
-                line: 31,
-                patch_set: 4,
-              }],
-            },
-            {}/* ported drafts */
-        );
-
-        const portedThreads = changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: PARENT});
-        assert.equal(portedThreads.length, 1);
-        assert.equal(portedThreads[0].line, 31);
-
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: -2}
-        ).length, 0);
-
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 2}
-        ).length, 0);
-      });
-
-      test('comments left on merge parent is not ported over', () => {
-        changeComments = new ChangeComments(
-            {/* comments */
-              // comment left on Base
-              'karma.conf.js': [comment4],
-            },
-            {}/* robot comments */,
-            {/* drafts */
-              'karma.conf.js': [draft2],
-            },
-            {/* ported comments */
-              'karma.conf.js': [{
-                ...comment4,
-                line: 31,
-                patch_set: 4,
-              }],
-            },
-            {}/* ported drafts */
-        );
-
-        const portedThreads = changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: PARENT});
-        assert.equal(portedThreads.length, 0);
-
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: -2}
-        ).length, 0);
-
-        assert.equal(changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 2}
-        ).length, 0);
-      });
-
-      test('ported comments contribute to comment count', () => {
-        assert.equal(changeComments.computeCommentsString(
-            {basePatchNum: PARENT, patchNum: 2}, 'karma.conf.js',
-            {__path: 'karma.conf.js'}), '2 comments (1 unresolved)');
-
-        // comment1 is ported over to patchset 4
-        assert.equal(changeComments.computeCommentsString(
-            {basePatchNum: PARENT, patchNum: 4}, 'karma.conf.js',
-            {__path: 'karma.conf.js'}), '1 comment (1 unresolved)');
-      });
-
-      test('drafts are ported over', () => {
-        changeComments = new ChangeComments(
-            {}/* comments */,
-            {}/* robotComments */,
-            {/* drafts */
-              // draft1: resolved draft that will be ported over to ps 4
-              // draft2: unresolved draft that will be ported over to ps 4
-              'karma.conf.js': [draft1, draft2],
-            },
-            {}/* ported comments */,
-            {/* ported drafts */
-              'karma.conf.js': [
-                {
-                  ...draft1,
-                  line: 5,
-                  patch_set: 4,
-                },
-                {
-                  ...draft2,
-                  line: 31,
-                  patch_set: 4,
-                },
-              ],
-            }
-        );
-
-        const portedThreads = changeComments._getPortedCommentThreads(
-            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: PARENT});
-
-        // resolved draft is ported over
-        assert.equal(portedThreads.length, 2);
-        assert.equal(portedThreads[0].line, 5);
-        assert.isTrue(isDraftThread(portedThreads[0]));
-        assert.isFalse(isUnresolved(portedThreads[0]));
-
-        // unresolved draft is ported over
-        assert.equal(portedThreads[1].line, 31);
-        assert.isTrue(isDraftThread(portedThreads[1]));
-        assert.isTrue(isUnresolved(portedThreads[1]));
-
-        assert.equal(createCommentThreads(
-            changeComments.getAllCommentsForPath('karma.conf.js'),
-            {patchNum: 4, basePatchNum: PARENT}).length, 0);
-      });
-    });
-
-    test('_isInBaseOfPatchRange', () => {
-      const comment = {patch_set: 1};
-      const patchRange = {basePatchNum: 1, patchNum: 2};
-      assert.isTrue(isInBaseOfPatchRange(comment,
-          patchRange));
-
-      patchRange.basePatchNum = PARENT;
-      assert.isFalse(isInBaseOfPatchRange(comment,
-          patchRange));
-
-      comment.side = PARENT;
-      assert.isFalse(isInBaseOfPatchRange(comment,
-          patchRange));
-
-      comment.patch_set = 2;
-      assert.isTrue(isInBaseOfPatchRange(comment,
-          patchRange));
-
-      patchRange.basePatchNum = -2;
-      comment.side = PARENT;
-      comment.parent = 1;
-      assert.isFalse(isInBaseOfPatchRange(comment,
-          patchRange));
-
-      comment.parent = 2;
-      assert.isTrue(isInBaseOfPatchRange(comment,
-          patchRange));
-    });
-
-    test('isInRevisionOfPatchRange', () => {
-      const comment = {patch_set: 123};
-      const patchRange = {basePatchNum: 122, patchNum: 124};
-      assert.isFalse(isInRevisionOfPatchRange(
-          comment, patchRange));
-
-      patchRange.patchNum = 123;
-      assert.isTrue(isInRevisionOfPatchRange(
-          comment, patchRange));
-
-      comment.side = PARENT;
-      assert.isFalse(isInRevisionOfPatchRange(
-          comment, patchRange));
-    });
-
-    suite('comment ranges and paths', () => {
-      const commentObjs = {};
-      function makeTime(mins) {
-        return `2013-02-26 15:0${mins}:43.986000000`;
-      }
-
-      setup(() => {
-        commentObjs['01'] = {
-          ...createComment(),
-          id: '01',
-          patch_set: 2,
-          path: 'file/one',
-          side: PARENT,
-          line: 1,
-          updated: makeTime(1),
-          range: {
-            start_line: 1,
-            start_character: 2,
-            end_line: 2,
-            end_character: 2,
-          },
-        };
-
-        commentObjs['02'] = {
-          ...createComment(),
-          id: '02',
-          in_reply_to: '04',
-          patch_set: 2,
-          path: 'file/one',
-          unresolved: true,
-          line: 1,
-          updated: makeTime(3),
-        };
-
-        commentObjs['03'] = {
-          ...createComment(),
-          id: '03',
-          patch_set: 2,
-          path: 'file/one',
-          side: PARENT,
-          line: 2,
-          updated: makeTime(1),
-        };
-
-        commentObjs['04'] = {
-          ...createComment(),
-          id: '04',
-          patch_set: 2,
-          path: 'file/one',
-          line: 1,
-          updated: makeTime(1),
-        };
-
-        commentObjs['05'] = {
-          ...createComment(),
-          id: '05',
-          patch_set: 2,
-          line: 2,
-          updated: makeTime(1),
-        };
-
-        commentObjs['06'] = {
-          ...createComment(),
-          id: '06',
-          patch_set: 3,
-          line: 2,
-          updated: makeTime(1),
-        };
-
-        commentObjs['07'] = {
-          ...createComment(),
-          id: '07',
-          patch_set: 2,
-          side: PARENT,
-          unresolved: false,
-          line: 1,
-          updated: makeTime(1),
-        };
-
-        commentObjs['08'] = {
-          ...createComment(),
-          id: '08',
-          patch_set: 2,
-          side: PARENT,
-          unresolved: true,
-          in_reply_to: '07',
-          line: 1,
-          updated: makeTime(1),
-        };
-
-        commentObjs['09'] = {
-          ...createComment(),
-          id: '09',
-          patch_set: 3,
-          line: 1,
-          updated: makeTime(1),
-        };
-
-        commentObjs['10'] = {
-          ...createComment(),
-          id: '10',
-          patch_set: 5,
-          side: PARENT,
-          line: 1,
-          updated: makeTime(1),
-        };
-
-        commentObjs['11'] = {
-          ...createComment(),
-          id: '11',
-          patch_set: 5,
-          line: 1,
-          updated: makeTime(1),
-        };
-
-        commentObjs['12'] = {
-          ...createDraft(),
-          id: '12',
-          patch_set: 2,
-          side: PARENT,
-          line: 1,
-          updated: makeTime(3),
-          path: 'file/one',
-        };
-
-        commentObjs['13'] = {
-          ...createDraft(),
-          id: '13',
-          in_reply_to: '04',
-          patch_set: 2,
-          line: 1,
-          // Draft gets lower timestamp than published comment, because we
-          // want to test that the draft still gets sorted to the end.
-          updated: makeTime(2),
-          path: 'file/one',
-        };
-
-        commentObjs['14'] = {
-          ...createDraft(),
-          id: '14',
-          patch_set: 3,
-          line: 1,
-          path: 'file/two',
-          updated: makeTime(3),
-        };
-
-        const drafts = {
-          'file/one': [
-            commentObjs['12'],
-            commentObjs['13'],
-          ],
-          'file/two': [
-            commentObjs['14'],
-          ],
-        };
-        const robotComments = {
-          'file/one': [
-            commentObjs['01'], commentObjs['02'],
-          ],
-        };
-        const comments = {
-          'file/one': [commentObjs['03'], commentObjs['04']],
-          'file/two': [commentObjs['05'], commentObjs['06']],
-          'file/three': [commentObjs['07'], commentObjs['08'],
-            commentObjs['09']],
-          'file/four': [commentObjs['10'], commentObjs['11']],
-        };
-        element._changeComments =
-            new ChangeComments(comments, robotComments, drafts, {}, {});
-      });
-
-      test('getPaths', () => {
-        const patchRange = {basePatchNum: 1, patchNum: 4};
-        let paths = element._changeComments.getPaths(patchRange);
-        assert.equal(Object.keys(paths).length, 0);
-
-        patchRange.basePatchNum = PARENT;
-        patchRange.patchNum = 3;
-        paths = element._changeComments.getPaths(patchRange);
-        assert.notProperty(paths, 'file/one');
-        assert.property(paths, 'file/two');
-        assert.property(paths, 'file/three');
-        assert.notProperty(paths, 'file/four');
-
-        patchRange.patchNum = 2;
-        paths = element._changeComments.getPaths(patchRange);
-        assert.property(paths, 'file/one');
-        assert.property(paths, 'file/two');
-        assert.property(paths, 'file/three');
-        assert.notProperty(paths, 'file/four');
-
-        paths = element._changeComments.getPaths();
-        assert.property(paths, 'file/one');
-        assert.property(paths, 'file/two');
-        assert.property(paths, 'file/three');
-        assert.property(paths, 'file/four');
-      });
-
-      test('getCommentsForPath', () => {
-        const patchRange = {basePatchNum: 1, patchNum: 3};
-        let path = 'file/one';
-        let comments = element._changeComments.getCommentsForPath(path,
-            patchRange);
-        assert.equal(comments.filter(c => isInBaseOfPatchRange(c, patchRange))
-            .length, 0);
-        assert.equal(comments.filter(c => isInRevisionOfPatchRange(c,
-            patchRange)).length, 0);
-
-        path = 'file/two';
-        comments = element._changeComments.getCommentsForPath(path,
-            patchRange);
-        assert.equal(comments.filter(c => isInBaseOfPatchRange(c, patchRange))
-            .length, 0);
-        assert.equal(comments.filter(c => isInRevisionOfPatchRange(c,
-            patchRange)).length, 2);
-
-        patchRange.basePatchNum = 2;
-        comments = element._changeComments.getCommentsForPath(path,
-            patchRange);
-        assert.equal(comments.filter(c => isInBaseOfPatchRange(c,
-            patchRange)).length, 1);
-        assert.equal(comments.filter(c => isInRevisionOfPatchRange(c,
-            patchRange)).length, 2);
-
-        patchRange.basePatchNum = PARENT;
-        path = 'file/three';
-        comments = element._changeComments.getCommentsForPath(path,
-            patchRange);
-        assert.equal(comments.filter(c => isInBaseOfPatchRange(c, patchRange))
-            .length, 0);
-        assert.equal(comments.filter(c => isInRevisionOfPatchRange(c,
-            patchRange)).length, 1);
-      });
-
-      test('getAllCommentsForPath', () => {
-        let path = 'file/one';
-        let comments = element._changeComments.getAllCommentsForPath(path);
-        assert.equal(comments.length, 4);
-        path = 'file/two';
-        comments = element._changeComments.getAllCommentsForPath(path, 2);
-        assert.equal(comments.length, 1);
-        const aCopyOfComments = element._changeComments
-            .getAllCommentsForPath(path, 2);
-        assert.deepEqual(comments, aCopyOfComments);
-        assert.notEqual(comments[0], aCopyOfComments[0]);
-      });
-
-      test('getAllDraftsForPath', () => {
-        const path = 'file/one';
-        const drafts = element._changeComments.getAllDraftsForPath(path);
-        assert.equal(drafts.length, 2);
-      });
-
-      test('computeUnresolvedNum', () => {
-        assert.equal(element._changeComments
-            .computeUnresolvedNum({
-              patchNum: 2,
-              path: 'file/one',
-            }), 0);
-        assert.equal(element._changeComments
-            .computeUnresolvedNum({
-              patchNum: 1,
-              path: 'file/one',
-            }), 0);
-        assert.equal(element._changeComments
-            .computeUnresolvedNum({
-              patchNum: 2,
-              path: 'file/three',
-            }), 1);
-      });
-
-      test('computeUnresolvedNum w/ non-linear thread', () => {
-        const comments = {
-          path: [{
-            id: '9c6ba3c6_28b7d467',
-            patch_set: 1,
-            updated: '2018-02-28 14:41:13.000000000',
-            unresolved: true,
-          }, {
-            id: '3df7b331_0bead405',
-            patch_set: 1,
-            in_reply_to: '1c346623_ab85d14a',
-            updated: '2018-02-28 23:07:55.000000000',
-            unresolved: false,
-          }, {
-            id: '6153dce6_69958d1e',
-            patch_set: 1,
-            in_reply_to: '9c6ba3c6_28b7d467',
-            updated: '2018-02-28 17:11:31.000000000',
-            unresolved: true,
-          }, {
-            id: '1c346623_ab85d14a',
-            patch_set: 1,
-            in_reply_to: '9c6ba3c6_28b7d467',
-            updated: '2018-02-28 23:01:39.000000000',
-            unresolved: false,
-          }],
-        };
-        element._changeComments = new ChangeComments(comments, {}, {}, 1234);
-        assert.equal(
-            element._changeComments.computeUnresolvedNum(1, 'path'), 0);
-      });
-
-      test('computeCommentsString', () => {
-        const changeComments = createChangeComments();
-        const parentTo1 = {
-          basePatchNum: PARENT,
-          patchNum: 1,
-        };
-        const parentTo2 = {
-          basePatchNum: PARENT,
-          patchNum: 2,
-        };
-        const _1To2 = {
-          basePatchNum: 1,
-          patchNum: 2,
-        };
-
-        assert.equal(
-            changeComments.computeCommentsString(parentTo1, '/COMMIT_MSG',
-                {__path: '/COMMIT_MSG'}), '2 comments (1 unresolved)');
-        assert.equal(
-            changeComments.computeCommentsString(parentTo1, '/COMMIT_MSG',
-                {__path: '/COMMIT_MSG', status: 'U'}, true),
-            '2 comments (1 unresolved)(no changes)');
-        assert.equal(
-            changeComments.computeCommentsString(_1To2, '/COMMIT_MSG',
-                {__path: '/COMMIT_MSG'}), '3 comments (1 unresolved)');
-
-        assert.equal(
-            changeComments.computeCommentsString(parentTo1, 'myfile.txt',
-                {__path: 'myfile.txt'}), '1 comment');
-        assert.equal(
-            changeComments.computeCommentsString(_1To2, 'myfile.txt',
-                {__path: 'myfile.txt'}), '3 comments');
-
-        assert.equal(
-            changeComments.computeCommentsString(parentTo1,
-                'file_added_in_rev2.txt',
-                {__path: 'file_added_in_rev2.txt'}), '');
-        assert.equal(
-            changeComments.computeCommentsString(_1To2,
-                'file_added_in_rev2.txt',
-                {__path: 'file_added_in_rev2.txt'}), '');
-
-        assert.equal(
-            changeComments.computeCommentsString(parentTo2, '/COMMIT_MSG',
-                {__path: '/COMMIT_MSG'}), '1 comment');
-        assert.equal(
-            changeComments.computeCommentsString(_1To2, '/COMMIT_MSG',
-                {__path: '/COMMIT_MSG'}), '3 comments (1 unresolved)');
-
-        assert.equal(
-            changeComments.computeCommentsString(parentTo2, 'myfile.txt',
-                {__path: 'myfile.txt'}), '2 comments');
-        assert.equal(
-            changeComments.computeCommentsString(_1To2, 'myfile.txt',
-                {__path: 'myfile.txt'}), '3 comments');
-
-        assert.equal(
-            changeComments.computeCommentsString(parentTo2,
-                'file_added_in_rev2.txt',
-                {__path: 'file_added_in_rev2.txt'}), '');
-        assert.equal(
-            changeComments.computeCommentsString(_1To2,
-                'file_added_in_rev2.txt',
-                {__path: 'file_added_in_rev2.txt'}), '');
-        assert.equal(
-            changeComments.computeCommentsString(parentTo2, 'unresolved.file',
-                {__path: 'unresolved.file'}), '2 comments (1 unresolved)');
-        assert.equal(
-            changeComments.computeCommentsString(_1To2, 'unresolved.file',
-                {__path: 'unresolved.file'}), '2 comments (1 unresolved)');
-      });
-
-      test('computeCommentThreadCount', () => {
-        assert.equal(element._changeComments
-            .computeCommentThreadCount({
-              patchNum: 2,
-              path: 'file/one',
-            }), 3);
-        assert.equal(element._changeComments
-            .computeCommentThreadCount({
-              patchNum: 1,
-              path: 'file/one',
-            }), 0);
-        assert.equal(element._changeComments
-            .computeCommentThreadCount({
-              patchNum: 2,
-              path: 'file/three',
-            }), 1);
-      });
-
-      test('computeDraftCount', () => {
-        assert.equal(element._changeComments
-            .computeDraftCount({
-              patchNum: 2,
-              path: 'file/one',
-            }), 2);
-        assert.equal(element._changeComments
-            .computeDraftCount({
-              patchNum: 1,
-              path: 'file/one',
-            }), 0);
-        assert.equal(element._changeComments
-            .computeDraftCount({
-              patchNum: 2,
-              path: 'file/three',
-            }), 0);
-        assert.equal(element._changeComments
-            .computeDraftCount(), 3);
-      });
-
-      test('getAllPublishedComments', () => {
-        let publishedComments = element._changeComments
-            .getAllPublishedComments();
-        assert.equal(Object.keys(publishedComments).length, 4);
-        assert.equal(Object.keys(publishedComments[['file/one']]).length, 4);
-        assert.equal(Object.keys(publishedComments[['file/two']]).length, 2);
-        publishedComments = element._changeComments
-            .getAllPublishedComments(2);
-        assert.equal(Object.keys(publishedComments[['file/one']]).length, 4);
-        assert.equal(Object.keys(publishedComments[['file/two']]).length, 1);
-      });
-
-      test('getAllComments', () => {
-        let comments = element._changeComments.getAllComments();
-        assert.equal(Object.keys(comments).length, 4);
-        assert.equal(Object.keys(comments[['file/one']]).length, 4);
-        assert.equal(Object.keys(comments[['file/two']]).length, 2);
-        comments = element._changeComments.getAllComments(false, 2);
-        assert.equal(Object.keys(comments).length, 4);
-        assert.equal(Object.keys(comments[['file/one']]).length, 4);
-        assert.equal(Object.keys(comments[['file/two']]).length, 1);
-        // Include drafts
-        comments = element._changeComments.getAllComments(true);
-        assert.equal(Object.keys(comments).length, 4);
-        assert.equal(Object.keys(comments[['file/one']]).length, 6);
-        assert.equal(Object.keys(comments[['file/two']]).length, 3);
-        comments = element._changeComments.getAllComments(true, 2);
-        assert.equal(Object.keys(comments).length, 4);
-        assert.equal(Object.keys(comments[['file/one']]).length, 6);
-        assert.equal(Object.keys(comments[['file/two']]).length, 1);
-      });
-
-      test('computeAllThreads', () => {
-        const expectedThreads = [
-          {
-            ...createCommentThread([{...commentObjs['01'], path: 'file/one'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['03'], path: 'file/one'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['04'], path: 'file/one'},
-              {...commentObjs['02'], path: 'file/one'},
-              {...commentObjs['13'], path: 'file/one'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['05'], path: 'file/two'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['06'], path: 'file/two'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['07'], path: 'file/three'},
-              {...commentObjs['08'], path: 'file/three'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['09'], path: 'file/three'}]
-            ),
-          }, {
-            ...createCommentThread([{...commentObjs['10'], path: 'file/four'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['11'], path: 'file/four'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['12'], path: 'file/one'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['14'], path: 'file/two'}]),
-          },
-        ];
-        const threads = element._changeComments.getAllThreadsForChange();
-        assert.deepEqual(threads, expectedThreads);
-      });
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
new file mode 100644
index 0000000..b195762
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
@@ -0,0 +1,1042 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import {ChangeComments} from './gr-comment-api';
+import {
+  isInRevisionOfPatchRange,
+  isInBaseOfPatchRange,
+  isDraftThread,
+  isUnresolved,
+  createCommentThreads,
+  DraftInfo,
+  CommentThread,
+} from '../../../utils/comment-util';
+import {
+  createDraft,
+  createComment,
+  createChangeComments,
+  createCommentThread,
+  createFileInfo,
+  createRobotComment,
+} from '../../../test/test-data-generators';
+import {CommentSide, FileInfoStatus} from '../../../constants/constants';
+import {
+  BasePatchSetNum,
+  CommentInfo,
+  PARENT,
+  PatchRange,
+  PatchSetNum,
+  PathToCommentsInfoMap,
+  RevisionPatchSetNum,
+  RobotCommentInfo,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {stubRestApi} from '../../../test/test-utils';
+
+suite('ChangeComments tests', () => {
+  let changeComments: ChangeComments;
+
+  suite('_changeComment methods', () => {
+    setup(() => {
+      stubRestApi('getDiffComments').resolves({});
+      stubRestApi('getDiffRobotComments').resolves({});
+      stubRestApi('getDiffDrafts').resolves({});
+    });
+
+    suite('ported comments', () => {
+      let portedComments: PathToCommentsInfoMap;
+      const comment1: CommentInfo = {
+        ...createComment(),
+        unresolved: true,
+        id: '1' as UrlEncodedCommentId,
+        line: 136,
+        patch_set: 2 as RevisionPatchSetNum,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 1,
+          end_character: 1,
+        },
+      };
+
+      const comment2: CommentInfo = {
+        ...createComment(),
+        patch_set: 2 as RevisionPatchSetNum,
+        id: '2' as UrlEncodedCommentId,
+        line: 5,
+      };
+
+      const comment3: CommentInfo = {
+        ...createComment(),
+        side: CommentSide.PARENT,
+        line: 10,
+        unresolved: true,
+      };
+
+      const comment4: CommentInfo = {
+        ...comment3,
+        parent: -2,
+      };
+
+      const draft1: DraftInfo = {
+        ...createDraft(),
+        id: 'db977012_e1f13828' as UrlEncodedCommentId,
+        line: 4,
+        patch_set: 2 as RevisionPatchSetNum,
+      };
+      const draft2: DraftInfo = {
+        ...createDraft(),
+        id: '503008e2_0ab203ee' as UrlEncodedCommentId,
+        line: 11,
+        unresolved: true,
+        // slightly larger timestamp so it's sorted higher
+        updated: '2018-02-13 22:49:48.018000001' as Timestamp,
+        patch_set: 2 as RevisionPatchSetNum,
+      };
+
+      setup(() => {
+        portedComments = {
+          'karma.conf.js': [
+            {
+              ...comment1,
+              patch_set: 4 as RevisionPatchSetNum,
+              range: {
+                start_line: 136,
+                start_character: 16,
+                end_line: 136,
+                end_character: 29,
+              },
+            },
+          ],
+        };
+
+        changeComments = new ChangeComments(
+          {
+            /* comments */
+            'karma.conf.js': [
+              // resolved comment that will not be ported over
+              comment2,
+              // original comment that will be ported over to patchset 4
+              comment1,
+            ],
+          },
+          {} /* robot comments */,
+          {} /* drafts */,
+          portedComments,
+          {} /* ported drafts */
+        );
+      });
+
+      test('threads containing ported comment are returned', () => {
+        assert.equal(changeComments.getAllThreadsForChange().length, 2);
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+          {path: 'karma.conf.js'},
+          {patchNum: 4 as RevisionPatchSetNum, basePatchNum: PARENT}
+        );
+
+        assert.equal(portedThreads.length, 1);
+        // check that the location of the thread matches the ported comment
+        assert.equal(portedThreads[0].patchNum, 4 as RevisionPatchSetNum);
+        assert.deepEqual(portedThreads[0].range, {
+          start_line: 136,
+          start_character: 16,
+          end_line: 136,
+          end_character: 29,
+        });
+
+        // thread ported over if comparing patchset 1 vs patchset 4
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 4 as RevisionPatchSetNum,
+              basePatchNum: 1 as BasePatchSetNum,
+            }
+          ).length,
+          1
+        );
+
+        // verify ported thread is not returned if original thread will be
+        // shown
+        // original thread attached to right side
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {patchNum: 2 as RevisionPatchSetNum, basePatchNum: PARENT}
+          ).length,
+          0
+        );
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 2 as RevisionPatchSetNum,
+              basePatchNum: 1 as BasePatchSetNum,
+            }
+          ).length,
+          0
+        );
+
+        // original thread attached to left side
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 3 as RevisionPatchSetNum,
+              basePatchNum: 2 as BasePatchSetNum,
+            }
+          ).length,
+          0
+        );
+      });
+
+      test('threads without any ported comment are filtered out', () => {
+        changeComments = new ChangeComments(
+          {
+            /* comments */
+            // comment that is not ported over
+            'karma.conf.js': [comment2],
+          },
+          {} /* robot comments */,
+          {
+            /* drafts */ 'karma.conf.js': [draft2],
+          },
+          // comment1 that is ported over but does not have any thread
+          // that has a comment that matches it
+          portedComments,
+          {} /* ported drafts */
+        );
+
+        assert.equal(
+          createCommentThreads(
+            changeComments.getAllCommentsForPath('karma.conf.js')
+          ).length,
+          1
+        );
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {patchNum: 4 as RevisionPatchSetNum, basePatchNum: PARENT}
+          ).length,
+          0
+        );
+      });
+
+      test('comments with side=PARENT are ported over', () => {
+        changeComments = new ChangeComments(
+          {
+            /* comments */
+            // comment left on Base
+            'karma.conf.js': [comment3],
+          },
+          {} /* robot comments */,
+          {
+            /* drafts */ 'karma.conf.js': [draft2],
+          },
+          {
+            /* ported comments */
+            'karma.conf.js': [
+              {
+                ...comment3,
+                line: 31,
+                patch_set: 4 as RevisionPatchSetNum,
+              },
+            ],
+          },
+          {} /* ported drafts */
+        );
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+          {path: 'karma.conf.js'},
+          {patchNum: 4 as RevisionPatchSetNum, basePatchNum: PARENT}
+        );
+        assert.equal(portedThreads.length, 1);
+        assert.equal(portedThreads[0].line, 31);
+
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 4 as RevisionPatchSetNum,
+              basePatchNum: -2 as BasePatchSetNum,
+            }
+          ).length,
+          0
+        );
+
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 4 as RevisionPatchSetNum,
+              basePatchNum: 2 as BasePatchSetNum,
+            }
+          ).length,
+          0
+        );
+      });
+
+      test('comments left on merge parent is not ported over', () => {
+        changeComments = new ChangeComments(
+          {
+            /* comments */
+            // comment left on Base
+            'karma.conf.js': [comment4],
+          },
+          {} /* robot comments */,
+          {
+            /* drafts */ 'karma.conf.js': [draft2],
+          },
+          {
+            /* ported comments */
+            'karma.conf.js': [
+              {
+                ...comment4,
+                line: 31,
+                patch_set: 4 as RevisionPatchSetNum,
+              },
+            ],
+          },
+          {} /* ported drafts */
+        );
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+          {path: 'karma.conf.js'},
+          {patchNum: 4 as RevisionPatchSetNum, basePatchNum: PARENT}
+        );
+        assert.equal(portedThreads.length, 0);
+
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 4 as RevisionPatchSetNum,
+              basePatchNum: -2 as BasePatchSetNum,
+            }
+          ).length,
+          0
+        );
+
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 4 as RevisionPatchSetNum,
+              basePatchNum: 2 as BasePatchSetNum,
+            }
+          ).length,
+          0
+        );
+      });
+
+      test('ported comments contribute to comment count', () => {
+        const fileInfo = createFileInfo();
+        assert.equal(
+          changeComments.computeCommentsString(
+            {basePatchNum: PARENT, patchNum: 2 as RevisionPatchSetNum},
+            'karma.conf.js',
+            fileInfo
+          ),
+          '2 comments (1 unresolved)'
+        );
+
+        // comment1 is ported over to patchset 4
+        assert.equal(
+          changeComments.computeCommentsString(
+            {basePatchNum: PARENT, patchNum: 4 as RevisionPatchSetNum},
+            'karma.conf.js',
+            fileInfo
+          ),
+          '1 comment (1 unresolved)'
+        );
+      });
+
+      test('drafts are ported over', () => {
+        changeComments = new ChangeComments(
+          {} /* comments */,
+          {} /* robotComments */,
+          {
+            /* drafts */
+            // draft1: resolved draft that will be ported over to ps 4
+            // draft2: unresolved draft that will be ported over to ps 4
+            'karma.conf.js': [draft1, draft2],
+          },
+          {} /* ported comments */,
+          {
+            /* ported drafts */
+            'karma.conf.js': [
+              {
+                ...draft1,
+                line: 5,
+                patch_set: 4 as RevisionPatchSetNum,
+              },
+              {
+                ...draft2,
+                line: 31,
+                patch_set: 4 as RevisionPatchSetNum,
+              },
+            ],
+          }
+        );
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+          {path: 'karma.conf.js'},
+          {patchNum: 4 as RevisionPatchSetNum, basePatchNum: PARENT}
+        );
+
+        // resolved draft is ported over
+        assert.equal(portedThreads.length, 2);
+        assert.equal(portedThreads[0].line, 5);
+        assert.isTrue(isDraftThread(portedThreads[0]));
+        assert.isFalse(isUnresolved(portedThreads[0]));
+
+        // unresolved draft is ported over
+        assert.equal(portedThreads[1].line, 31);
+        assert.isTrue(isDraftThread(portedThreads[1]));
+        assert.isTrue(isUnresolved(portedThreads[1]));
+
+        assert.equal(
+          createCommentThreads(
+            changeComments.getAllCommentsForPath('karma.conf.js')
+          ).length,
+          0
+        );
+      });
+    });
+
+    test('_isInBaseOfPatchRange', () => {
+      const comment: {
+        patch_set?: PatchSetNum;
+        side?: CommentSide;
+        parent?: number;
+      } = {patch_set: 1 as PatchSetNum};
+      const patchRange = {
+        basePatchNum: 1 as BasePatchSetNum,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+      assert.isTrue(isInBaseOfPatchRange(comment, patchRange));
+
+      patchRange.basePatchNum = PARENT;
+      assert.isFalse(isInBaseOfPatchRange(comment, patchRange));
+
+      comment.side = CommentSide.PARENT;
+      assert.isFalse(isInBaseOfPatchRange(comment, patchRange));
+
+      comment.patch_set = 2 as PatchSetNum;
+      assert.isTrue(isInBaseOfPatchRange(comment, patchRange));
+
+      patchRange.basePatchNum = -2 as BasePatchSetNum;
+      comment.side = CommentSide.PARENT;
+      comment.parent = 1;
+      assert.isFalse(isInBaseOfPatchRange(comment, patchRange));
+
+      comment.parent = 2;
+      assert.isTrue(isInBaseOfPatchRange(comment, patchRange));
+    });
+
+    test('isInRevisionOfPatchRange', () => {
+      const comment: {
+        patch_set?: PatchSetNum;
+        side?: CommentSide;
+      } = {patch_set: 123 as PatchSetNum};
+      const patchRange: PatchRange = {
+        basePatchNum: 122 as BasePatchSetNum,
+        patchNum: 124 as RevisionPatchSetNum,
+      };
+      assert.isFalse(isInRevisionOfPatchRange(comment, patchRange));
+
+      patchRange.patchNum = 123 as RevisionPatchSetNum;
+      assert.isTrue(isInRevisionOfPatchRange(comment, patchRange));
+
+      comment.side = CommentSide.PARENT;
+      assert.isFalse(isInRevisionOfPatchRange(comment, patchRange));
+    });
+
+    suite('comment ranges and paths', () => {
+      const comments = [
+        {
+          ...createRobotComment(),
+          id: '01' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          path: 'file/one',
+          side: CommentSide.PARENT,
+          line: 1,
+          updated: makeTime(1),
+          range: {
+            start_line: 1,
+            start_character: 2,
+            end_line: 2,
+            end_character: 2,
+          },
+        },
+        {
+          ...createRobotComment(),
+          id: '02' as UrlEncodedCommentId,
+          in_reply_to: '04' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          path: 'file/one',
+          unresolved: true,
+          line: 1,
+          updated: makeTime(3),
+        },
+        {
+          ...createComment(),
+          id: '03' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          path: 'file/one',
+          side: CommentSide.PARENT,
+          line: 2,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '04' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          path: 'file/one',
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '05' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          line: 2,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '06' as UrlEncodedCommentId,
+          patch_set: 3 as RevisionPatchSetNum,
+          line: 2,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '07' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          side: CommentSide.PARENT,
+          unresolved: false,
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '08' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          side: CommentSide.PARENT,
+          unresolved: true,
+          in_reply_to: '07' as UrlEncodedCommentId,
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '09' as UrlEncodedCommentId,
+          patch_set: 3 as RevisionPatchSetNum,
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '10' as UrlEncodedCommentId,
+          patch_set: 5 as RevisionPatchSetNum,
+          side: CommentSide.PARENT,
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '11' as UrlEncodedCommentId,
+          patch_set: 5 as RevisionPatchSetNum,
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createDraft(),
+          id: '12' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          side: CommentSide.PARENT,
+          line: 1,
+          updated: makeTime(3),
+          path: 'file/one',
+        },
+        {
+          ...createDraft(),
+          id: '13' as UrlEncodedCommentId,
+          in_reply_to: '04' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          line: 1,
+          // Draft gets lower timestamp than published comment, because we
+          // want to test that the draft still gets sorted to the end.
+          updated: makeTime(2),
+          path: 'file/one',
+        },
+        {
+          ...createDraft(),
+          id: '14' as UrlEncodedCommentId,
+          patch_set: 3 as RevisionPatchSetNum,
+          line: 1,
+          path: 'file/two',
+          updated: makeTime(3),
+        },
+      ] as const;
+      const drafts: {[path: string]: DraftInfo[]} = {
+        'file/one': [comments[11], comments[12]],
+        'file/two': [comments[13]],
+      };
+      const robotComments: {[path: string]: RobotCommentInfo[]} = {
+        'file/one': [comments[0], comments[1]],
+      };
+      const commentsByFile: PathToCommentsInfoMap = {
+        'file/one': [comments[2], comments[3]],
+        'file/two': [comments[4], comments[5]],
+        'file/three': [comments[6], comments[7], comments[8]],
+        'file/four': [comments[9], comments[10]],
+      };
+
+      function makeTime(mins: number) {
+        return `2013-02-26 15:0${mins}:43.986000000` as Timestamp;
+      }
+
+      setup(() => {
+        changeComments = new ChangeComments(
+          commentsByFile,
+          robotComments,
+          drafts,
+          {} /* portedComments */,
+          {} /* portedDrafts */
+        );
+      });
+
+      test('getPaths', () => {
+        const patchRange: PatchRange = {
+          basePatchNum: 1 as BasePatchSetNum,
+          patchNum: 4 as RevisionPatchSetNum,
+        };
+        let paths = changeComments.getPaths(patchRange);
+        assert.equal(Object.keys(paths).length, 0);
+
+        patchRange.basePatchNum = PARENT;
+        patchRange.patchNum = 3 as RevisionPatchSetNum;
+        paths = changeComments.getPaths(patchRange);
+        assert.notProperty(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+        assert.notProperty(paths, 'file/four');
+
+        patchRange.patchNum = 2 as RevisionPatchSetNum;
+        paths = changeComments.getPaths(patchRange);
+        assert.property(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+        assert.notProperty(paths, 'file/four');
+
+        paths = changeComments.getPaths();
+        assert.property(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+        assert.property(paths, 'file/four');
+      });
+
+      test('getCommentsForPath', () => {
+        const patchRange: PatchRange = {
+          basePatchNum: 1 as BasePatchSetNum,
+          patchNum: 3 as RevisionPatchSetNum,
+        };
+        let path = 'file/one';
+        let comments = changeComments.getCommentsForPath(path, patchRange);
+        assert.equal(
+          comments.filter(c => isInBaseOfPatchRange(c, patchRange)).length,
+          0
+        );
+        assert.equal(
+          comments.filter(c => isInRevisionOfPatchRange(c, patchRange)).length,
+          0
+        );
+
+        path = 'file/two';
+        comments = changeComments.getCommentsForPath(path, patchRange);
+        assert.equal(
+          comments.filter(c => isInBaseOfPatchRange(c, patchRange)).length,
+          0
+        );
+        assert.equal(
+          comments.filter(c => isInRevisionOfPatchRange(c, patchRange)).length,
+          2
+        );
+
+        patchRange.basePatchNum = 2 as BasePatchSetNum;
+        comments = changeComments.getCommentsForPath(path, patchRange);
+        assert.equal(
+          comments.filter(c => isInBaseOfPatchRange(c, patchRange)).length,
+          1
+        );
+        assert.equal(
+          comments.filter(c => isInRevisionOfPatchRange(c, patchRange)).length,
+          2
+        );
+
+        patchRange.basePatchNum = PARENT;
+        path = 'file/three';
+        comments = changeComments.getCommentsForPath(path, patchRange);
+        assert.equal(
+          comments.filter(c => isInBaseOfPatchRange(c, patchRange)).length,
+          0
+        );
+        assert.equal(
+          comments.filter(c => isInRevisionOfPatchRange(c, patchRange)).length,
+          1
+        );
+      });
+
+      test('getAllCommentsForPath', () => {
+        let path = 'file/one';
+        let comments = changeComments.getAllCommentsForPath(path);
+        assert.equal(comments.length, 4);
+        path = 'file/two';
+        comments = changeComments.getAllCommentsForPath(path, 2 as PatchSetNum);
+        assert.equal(comments.length, 1);
+        const aCopyOfComments = changeComments.getAllCommentsForPath(
+          path,
+          2 as PatchSetNum
+        );
+        assert.deepEqual(comments, aCopyOfComments);
+        assert.notEqual(comments[0], aCopyOfComments[0]);
+      });
+
+      test('getAllDraftsForPath', () => {
+        const path = 'file/one';
+        const drafts = changeComments.getAllDraftsForPath(path);
+        assert.equal(drafts.length, 2);
+      });
+
+      test('computeUnresolvedNum', () => {
+        assert.equal(
+          changeComments.computeUnresolvedNum({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/one',
+          }),
+          0
+        );
+        assert.equal(
+          changeComments.computeUnresolvedNum({
+            patchNum: 1 as PatchSetNum,
+            path: 'file/one',
+          }),
+          0
+        );
+        assert.equal(
+          changeComments.computeUnresolvedNum({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/three',
+          }),
+          1
+        );
+      });
+
+      test('computeUnresolvedNum w/ non-linear thread', () => {
+        const comments: PathToCommentsInfoMap = {
+          path: [
+            {
+              id: '9c6ba3c6_28b7d467' as UrlEncodedCommentId,
+              patch_set: 1 as RevisionPatchSetNum,
+              updated: '2018-02-28 14:41:13.000000000' as Timestamp,
+              unresolved: true,
+            },
+            {
+              id: '3df7b331_0bead405' as UrlEncodedCommentId,
+              patch_set: 1 as RevisionPatchSetNum,
+              in_reply_to: '1c346623_ab85d14a' as UrlEncodedCommentId,
+              updated: '2018-02-28 23:07:55.000000000' as Timestamp,
+              unresolved: false,
+            },
+            {
+              id: '6153dce6_69958d1e' as UrlEncodedCommentId,
+              patch_set: 1 as RevisionPatchSetNum,
+              in_reply_to: '9c6ba3c6_28b7d467' as UrlEncodedCommentId,
+              updated: '2018-02-28 17:11:31.000000000' as Timestamp,
+              unresolved: true,
+            },
+            {
+              id: '1c346623_ab85d14a' as UrlEncodedCommentId,
+              patch_set: 1 as RevisionPatchSetNum,
+              in_reply_to: '9c6ba3c6_28b7d467' as UrlEncodedCommentId,
+              updated: '2018-02-28 23:01:39.000000000' as Timestamp,
+              unresolved: false,
+            },
+          ],
+        };
+        changeComments = new ChangeComments(comments, {}, {}, {});
+        assert.equal(
+          changeComments.computeUnresolvedNum(
+            {patchNum: 1 as PatchSetNum},
+            true
+          ),
+          0
+        );
+      });
+
+      test('computeCommentsString', () => {
+        const changeComments = createChangeComments();
+        const parentTo1: PatchRange = {
+          basePatchNum: PARENT,
+          patchNum: 1 as RevisionPatchSetNum,
+        };
+        const parentTo2: PatchRange = {
+          basePatchNum: PARENT,
+          patchNum: 2 as RevisionPatchSetNum,
+        };
+        const _1To2: PatchRange = {
+          basePatchNum: 1 as BasePatchSetNum,
+          patchNum: 2 as RevisionPatchSetNum,
+        };
+        const fileInfo = createFileInfo();
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo1,
+            '/COMMIT_MSG',
+            fileInfo
+          ),
+          '2 comments (1 unresolved)'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo1,
+            '/COMMIT_MSG',
+            {...fileInfo, status: FileInfoStatus.UNMODIFIED},
+            true
+          ),
+          '2 comments (1 unresolved)(no changes)'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(_1To2, '/COMMIT_MSG', fileInfo),
+          '3 comments (1 unresolved)'
+        );
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo1,
+            'myfile.txt',
+            fileInfo
+          ),
+          '1 comment'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(_1To2, 'myfile.txt', fileInfo),
+          '3 comments'
+        );
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo1,
+            'file_added_in_rev2.txt',
+            fileInfo
+          ),
+          ''
+        );
+        assert.equal(
+          changeComments.computeCommentsString(
+            _1To2,
+            'file_added_in_rev2.txt',
+            fileInfo
+          ),
+          ''
+        );
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo2,
+            '/COMMIT_MSG',
+            fileInfo
+          ),
+
+          '1 comment'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(_1To2, '/COMMIT_MSG', fileInfo),
+          '3 comments (1 unresolved)'
+        );
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo2,
+            'myfile.txt',
+            fileInfo
+          ),
+          '2 comments'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(_1To2, 'myfile.txt', fileInfo),
+          '3 comments'
+        );
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo2,
+            'file_added_in_rev2.txt',
+            fileInfo
+          ),
+          ''
+        );
+        assert.equal(
+          changeComments.computeCommentsString(
+            _1To2,
+            'file_added_in_rev2.txt',
+            fileInfo
+          ),
+          ''
+        );
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo2,
+            'unresolved.file',
+            fileInfo
+          ),
+          '2 comments (1 unresolved)'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(
+            _1To2,
+            'unresolved.file',
+            fileInfo
+          ),
+          '2 comments (1 unresolved)'
+        );
+      });
+
+      test('computeCommentThreadCount', () => {
+        assert.equal(
+          changeComments.computeCommentThreadCount({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/one',
+          }),
+          3
+        );
+        assert.equal(
+          changeComments.computeCommentThreadCount({
+            patchNum: 1 as PatchSetNum,
+            path: 'file/one',
+          }),
+          0
+        );
+        assert.equal(
+          changeComments.computeCommentThreadCount({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/three',
+          }),
+          1
+        );
+      });
+
+      test('computeDraftCount', () => {
+        assert.equal(
+          changeComments.computeDraftCount({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/one',
+          }),
+          2
+        );
+        assert.equal(
+          changeComments.computeDraftCount({
+            patchNum: 1 as PatchSetNum,
+            path: 'file/one',
+          }),
+          0
+        );
+        assert.equal(
+          changeComments.computeDraftCount({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/three',
+          }),
+          0
+        );
+        assert.equal(changeComments.computeDraftCount(), 3);
+      });
+
+      test('getAllPublishedComments', () => {
+        let publishedComments = changeComments.getAllPublishedComments();
+        assert.equal(Object.keys(publishedComments).length, 4);
+        assert.equal(Object.keys(publishedComments['file/one']).length, 4);
+        assert.equal(Object.keys(publishedComments['file/two']).length, 2);
+        publishedComments = changeComments.getAllPublishedComments(
+          2 as PatchSetNum
+        );
+        assert.equal(Object.keys(publishedComments['file/one']).length, 4);
+        assert.equal(Object.keys(publishedComments['file/two']).length, 1);
+      });
+
+      test('getAllComments', () => {
+        let comments = changeComments.getAllComments();
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments['file/one']).length, 4);
+        assert.equal(Object.keys(comments['file/two']).length, 2);
+        comments = changeComments.getAllComments(false, 2 as PatchSetNum);
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments['file/one']).length, 4);
+        assert.equal(Object.keys(comments['file/two']).length, 1);
+        // Include drafts
+        comments = changeComments.getAllComments(true);
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments['file/one']).length, 6);
+        assert.equal(Object.keys(comments['file/two']).length, 3);
+        comments = changeComments.getAllComments(true, 2 as PatchSetNum);
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments['file/one']).length, 6);
+        assert.equal(Object.keys(comments['file/two']).length, 1);
+      });
+
+      test('computeAllThreads', () => {
+        const expectedThreads: CommentThread[] = [
+          {
+            ...createCommentThread([{...comments[0], path: 'file/one'}]),
+          },
+          {
+            ...createCommentThread([{...comments[2], path: 'file/one'}]),
+          },
+          {
+            ...createCommentThread([
+              {...comments[3], path: 'file/one'},
+              {...comments[1], path: 'file/one'},
+              {...comments[12], path: 'file/one'},
+            ]),
+          },
+          {
+            ...createCommentThread([{...comments[4], path: 'file/two'}]),
+          },
+          {
+            ...createCommentThread([{...comments[5], path: 'file/two'}]),
+          },
+          {
+            ...createCommentThread([
+              {...comments[6], path: 'file/three'},
+              {...comments[7], path: 'file/three'},
+            ]),
+          },
+          {
+            ...createCommentThread([{...comments[8], path: 'file/three'}]),
+          },
+          {
+            ...createCommentThread([{...comments[9], path: 'file/four'}]),
+          },
+          {
+            ...createCommentThread([{...comments[10], path: 'file/four'}]),
+          },
+          {
+            ...createCommentThread([{...comments[11], path: 'file/one'}]),
+          },
+          {
+            ...createCommentThread([{...comments[13], path: 'file/two'}]),
+          },
+        ];
+        const threads = changeComments.getAllThreadsForChange();
+        assert.deepEqual(threads, expectedThreads);
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index ba87e3e..aff8442 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -10,9 +10,9 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-select/gr-select';
 import '../../shared/revision-info/revision-info';
-import '../gr-comment-api/gr-comment-api';
 import '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 import '../gr-apply-fix-dialog/gr-apply-fix-dialog';
 import '../gr-diff-host/gr-diff-host';
@@ -118,7 +118,6 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {ifDefined} from 'lit/directives/if-defined';
 import {when} from 'lit/directives/when';
-import {iconStyles} from '../../../styles/gr-icon-styles';
 
 const LOADING_BLAME = 'Loading blame...';
 const LOADED_BLAME = 'Blame loaded';
@@ -499,7 +498,6 @@
   static override get styles() {
     return [
       a11yStyles,
-      iconStyles,
       sharedStyles,
       css`
         :host {
@@ -977,8 +975,8 @@
                   link=""
                   class="prefsButton"
                   @click=${(e: Event) => this.handlePrefsTap(e)}
-                  ><span class="material-icon filled">settings</span></gr-button
-                >
+                  ><gr-icon icon="settings" filled></gr-icon
+                ></gr-button>
               </gr-tooltip-content>
             </span>
           </span>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index aa92a1d..6bf2470 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -572,7 +572,7 @@
                       role="button"
                       tabindex="0"
                     >
-                      <span class="filled material-icon">settings</span>
+                      <gr-icon icon="settings" filled></gr-icon>
                     </gr-button>
                   </gr-tooltip-content>
                 </span>
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
index 28aac53..2fe3de0 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
@@ -4,7 +4,6 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../test/common-test-setup-karma';
-import '../gr-comment-api/gr-comment-api';
 import '../../shared/revision-info/revision-info';
 import './gr-patch-range-select';
 import {GrPatchRangeSelect} from './gr-patch-range-select';
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts
index 071e1ce..285380e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts
@@ -21,6 +21,18 @@
     await element.updateComplete;
   });
 
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-autocomplete
+        allow-non-suggested-values="false"
+        clear-on-commit=""
+        id="input"
+        warn-uncommitted=""
+      >
+      </gr-autocomplete>
+    `);
+  });
+
   test('account-text-changed fired when input text changed and allowAnyInput', async () => {
     // Spy on query, as that is called when _updateSuggestions proceeds.
     const changeStub = sinon.stub();
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
index 7a00ba0..643d14c 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
@@ -12,6 +12,8 @@
   let element: GrAlert;
 
   setup(() => {
+    // The gr-alert element attaches itself to the root element on .show(),
+    // rather than existing under a fixture parent.
     element = document.createElement('gr-alert');
   });
 
@@ -21,6 +23,26 @@
     }
   });
 
+  test('render', async () => {
+    element.show('Alert text');
+    await element.updateComplete;
+
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div class="content-wrapper">
+        <span class="text"> Alert text </span>
+        <gr-button
+          aria-disabled="false"
+          class="action"
+          hidden=""
+          link=""
+          role="button"
+          tabindex="0"
+        >
+        </gr-button>
+      </div>
+    `);
+  });
+
   test('show/hide', async () => {
     assert.isNull(element.parentNode);
     element.show('Alert text');
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
index 42d088a..c9c3922 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
@@ -31,6 +31,45 @@
     element.close();
   });
 
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div
+        class="dropdown-content"
+        id="suggestions"
+        role="listbox"
+        slot="dropdown-content"
+      >
+        <ul>
+          <li
+            aria-label="test name 1"
+            class="autocompleteOption selected"
+            data-index="0"
+            data-value="test value 1"
+            role="option"
+            tabindex="-1"
+          >
+            <span> 1 </span>
+            <span class="label"> hi </span>
+          </li>
+          <li
+            aria-label="test name 2"
+            class="autocompleteOption"
+            data-index="1"
+            data-value="test value 2"
+            role="option"
+            tabindex="-1"
+          >
+            <span> 2 </span>
+            <span class="hide label"> </span>
+          </li>
+          <dom-repeat style="display: none;">
+            <template is="dom-repeat"> </template>
+          </dom-repeat>
+        </ul>
+      </div>
+    `);
+  });
+
   test('shows labels', () => {
     const els = queryAll<HTMLElement>(suggestionsEl(), 'li');
     assert.equal(els[0].innerText.trim(), '1\nhi');
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 f1705c3..deafa90 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -6,7 +6,6 @@
 import '@polymer/paper-input/paper-input';
 import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import '../gr-cursor-manager/gr-cursor-manager';
-import '../gr-icons/gr-icons';
 import '../../../styles/shared-styles';
 import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {fire, fireEvent} from '../../../utils/event-util';
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
index b851624..73e472c 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
@@ -7,8 +7,7 @@
 import './gr-autocomplete';
 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, waitUntil} from '../../../test/test-utils';
+import {queryAndAssert, waitUntil} from '../../../test/test-utils';
 import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {fixture, html} from '@open-wc/testing-helpers';
@@ -30,45 +29,104 @@
     element = await fixture(
       html`<gr-autocomplete no-debounce></gr-autocomplete>`
     );
-    await element.updateComplete;
   });
 
-  test('renders', async () => {
-    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[]))
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <paper-input
+        aria-disabled="false"
+        autocomplete="off"
+        id="input"
+        tabindex="0"
+      >
+        <div slot="prefix">
+          <span class="material-icon searchIcon"> search </span>
+        </div>
+        <div slot="suffix">
+          <slot name="suffix"> </slot>
+        </div>
+      </paper-input>
+      <gr-autocomplete-dropdown
+        horizontal-align="left"
+        id="suggestions"
+        is-hidden=""
+        role="listbox"
+        style="position: fixed; top: 300px; left: 392.5px; box-sizing: border-box; max-height: 600px; max-width: 785px;"
+        vertical-align="top"
+      >
+      </gr-autocomplete-dropdown>
+    `);
+  });
+
+  test('renders with suggestions', async () => {
+    const queryStub = sinon.spy((input: string) =>
+      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);
+
+    focusOnInput();
+    element.text = 'blah';
+    await waitUntil(() => queryStub.called);
+    await element.updateComplete;
+
+    expect(element).shadowDom.to.equal(
+      /* HTML */ `
+        <paper-input
+          aria-disabled="false"
+          autocomplete="off"
+          id="input"
+          tabindex="0"
+        >
+          <div slot="prefix">
+            <span class="material-icon searchIcon"> search </span>
+          </div>
+          <div slot="suffix">
+            <slot name="suffix"> </slot>
+          </div>
+        </paper-input>
+        <gr-autocomplete-dropdown
+          horizontal-align="left"
+          id="suggestions"
+          role="listbox"
+          vertical-align="top"
+        >
+        </gr-autocomplete-dropdown>
+      `,
+      {
+        // gr-autocomplete-dropdown sizing seems to vary between local & CI
+        ignoreAttributes: [
+          {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
+        ],
+      }
+    );
+  });
+
+  test('cursor starts on suggestions', async () => {
+    const queryStub = sinon.spy((input: string) =>
+      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.equal(suggestionsEl().cursor.index, -1);
 
     focusOnInput();
     element.text = 'blah';
     await waitUntil(() => queryStub.called);
+    await element.updateComplete;
 
-    assert.isTrue(queryStub.called);
-    element.setFocus(true);
-
-    assertIsDefined(promise);
-    return promise.then(async () => {
-      await element.updateComplete;
-      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);
-    });
+    assert.notEqual(suggestionsEl().cursor.index, -1);
   });
 
   test('selectAll', async () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
index 56de0b6..460f167 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -66,10 +66,10 @@
       this.hidden = true;
       return;
     }
+    this.hidden = false;
 
     const url = this.buildAvatarURL(this.account);
     if (url) {
-      this.hidden = false;
       this.style.backgroundImage = `url("${url}")`;
     }
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts
index 4179eb5..73daa7e 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts
@@ -6,6 +6,7 @@
 import '../../../test/common-test-setup-karma';
 import {queryAndAssert} from '../../../test/test-utils';
 import {GrChangeStar} from './gr-change-star';
+import './gr-change-star';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {createChange} from '../../../test/test-data-generators';
 
@@ -23,21 +24,32 @@
     await element.updateComplete;
   });
 
-  test('star visibility states', async () => {
-    element.change!.starred = true;
-    await element.updateComplete;
-    let icon = queryAndAssert<HTMLSpanElement>(element, '.material-icon');
-    assert.isTrue(icon.classList.contains('filled'));
-    assert.isTrue(icon.classList.contains('active'));
-    assert.equal(icon.innerText, 'grade');
+  test('renders starred', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <button
+        aria-label="Unstar this change"
+        role="checkbox"
+        title="Star/unstar change (shortcut: s)"
+      >
+        <span class="active filled material-icon"> grade </span>
+      </button>
+    `);
+  });
 
+  test('renders unstarred', async () => {
     element.change!.starred = false;
     element.requestUpdate('change');
     await element.updateComplete;
-    icon = queryAndAssert<HTMLSpanElement>(element, '.material-icon');
-    assert.isFalse(icon.classList.contains('filled'));
-    assert.isFalse(icon.classList.contains('active'));
-    assert.equal(icon.innerText, 'grade');
+
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <button
+        aria-label="Star this change"
+        role="checkbox"
+        title="Star/unstar change (shortcut: s)"
+      >
+        <span class="material-icon"> grade </span>
+      </button>
+    `);
   });
 
   test('starring', async () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
index 9139614..f38444a 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
@@ -25,6 +25,22 @@
     `);
   });
 
+  test('render', async () => {
+    element.status = ChangeStates.WIP;
+    await element.updateComplete;
+
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-tooltip-content
+        has-tooltip=""
+        max-width="40em"
+        position-below=""
+        title="This change isn't ready to be reviewed or submitted. It will not appear on dashboards unless you are CC'ed, and email notifications will be silenced until the review is started."
+      >
+        <div aria-label="Label: WIP" class="chip">Work in Progress</div>
+      </gr-tooltip-content>
+    `);
+  });
+
   test('WIP', async () => {
     element.status = ChangeStates.WIP;
     await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 73755ae..67f7b1a 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -85,11 +85,6 @@
   test('renders with draft', async () => {
     element.thread = createThread(c1, c2, c3);
     await element.updateComplete;
-  });
-
-  test('renders with draft', async () => {
-    element.thread = createThread(c1, c2, c3);
-    await element.updateComplete;
     expect(element).shadowDom.to.equal(/* HTML */ `
       <div class="fileName">
         <span>test-path-comment-thread</span>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
index 6c66a1c..4a3dcb3 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
@@ -6,6 +6,7 @@
 import '../gr-dialog/gr-dialog';
 import {css, html, LitElement} from 'lit';
 import {property, query, customElement} from 'lit/decorators';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {assertIsDefined} from '../../../utils/common-util';
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts
new file mode 100644
index 0000000..496c513
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {GrConfirmDeleteCommentDialog} from './gr-confirm-delete-comment-dialog';
+import './gr-confirm-delete-comment-dialog';
+
+suite('gr-confirm-delete-comment-dialog tests', () => {
+  let element: GrConfirmDeleteCommentDialog;
+
+  setup(async () => {
+    element = await fixture(
+      html`<gr-confirm-delete-comment-dialog></gr-confirm-delete-comment-dialog>`
+    );
+  });
+
+  test('render', () => {
+    // prettier and shadowDom string disagree about wrapping in <p> tag.
+    expect(element).shadowDom.to
+      .equal(/* prettier-ignore */ /* HTML */ `
+      <gr-dialog confirm-label="Delete" role="dialog">
+        <div class="header" slot="header">Delete Comment</div>
+        <div class="main" slot="main">
+          <p>
+            This is an admin function. Please only use in exceptional
+          circumstances.
+          </p>
+          <label for="messageInput"> Enter comment delete reason </label>
+          <iron-autogrow-textarea
+            aria-disabled="false"
+            autocomplete="on"
+            class="message"
+            id="messageInput"
+            placeholder="<Insert reasoning here>"
+          >
+          </iron-autogrow-textarea>
+        </div>
+      </gr-dialog>
+    `);
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
index 589ea0a..5d5912e 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
@@ -21,6 +21,35 @@
     await flush();
   });
 
+  test('render', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div class="text">
+        <iron-input class="copyText">
+          <input
+            id="input"
+            is="iron-input"
+            part="text-container-style"
+            readonly=""
+            type="text"
+          />
+        </iron-input>
+        <gr-tooltip-content>
+          <gr-button
+            aria-disabled="false"
+            aria-label="Click to copy to clipboard"
+            class="copyToClipboard"
+            id="copy-clipboard-button"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            <span class="material-icon" id="icon"> content_copy </span>
+          </gr-button>
+        </gr-tooltip-content>
+      </div>
+    `);
+  });
+
   test('copy to clipboard', () => {
     const clipboardSpy = sinon.spy(navigator.clipboard, 'writeText');
     const copyBtn = queryAndAssert(element, '.copyToClipboard');
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
index 6119a82..be25922 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
@@ -5,7 +5,7 @@
  */
 import '../gr-tooltip-content/gr-tooltip-content';
 import {css, html, LitElement} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators';
 import {
   parseDate,
   fromNow,
@@ -80,27 +80,22 @@
   @property({type: Boolean})
   showYesterday = false;
 
-  /** @type {?{short: string, full: string}} */
-  @property({type: Object})
-  private dateFormat?: DateFormatPair;
-
-  @property({type: String})
-  private timeFormat?: string;
-
-  @property({type: Boolean})
-  private relative = false;
-
   @property({type: Boolean})
   forceRelative = false;
 
   @property({type: Boolean})
   relativeOptionNoAgo = false;
 
-  private readonly restApiService = getAppContext().restApiService;
+  @state()
+  dateFormat?: DateFormatPair;
 
-  constructor() {
-    super();
-  }
+  @state()
+  timeFormat?: string;
+
+  @state()
+  relative = false;
+
+  private readonly restApiService = getAppContext().restApiService;
 
   static override get styles() {
     return [
@@ -130,12 +125,12 @@
   }
 
   private renderDateString() {
-    return html` <span>${this._computeDateStr()}</span>`;
+    return html` <span>${this.computeDateStr()}</span>`;
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this._loadPreferences();
+    this.loadPreferences();
   }
 
   // private but used by tests
@@ -144,27 +139,25 @@
   }
 
   // private but used by tests
-  _loadPreferences() {
-    return this._getLoggedIn().then(loggedIn => {
-      if (!loggedIn) {
-        this.timeFormat = TimeFormats.TIME_24;
-        this.dateFormat = DateFormats.STD;
-        this.relative = this.forceRelative;
-        return;
-      }
-      return Promise.all([this._loadTimeFormat(), this.loadRelative()]);
-    });
+  async loadPreferences() {
+    const loggedIn = await this.restApiService.getLoggedIn();
+    if (!loggedIn) {
+      this.timeFormat = TimeFormats.TIME_24;
+      this.dateFormat = DateFormats.STD;
+      this.relative = this.forceRelative;
+      return;
+    }
+    await Promise.all([this.loadTimeFormat(), this.loadRelative()]);
   }
 
-  // private but used in gr/file-list_test.js
-  _loadTimeFormat() {
-    return this.getPreferences().then(preferences => {
-      if (!preferences) {
-        throw Error('Preferences is not set');
-      }
-      this.decideTimeFormat(preferences.time_format);
-      this.decideDateFormat(preferences.date_format);
-    });
+  // private but used in gr/file-list_test.ts
+  async loadTimeFormat() {
+    const preferences = await this.restApiService.getPreferences();
+    if (!preferences) {
+      throw Error('Preferences is not set');
+    }
+    this.decideTimeFormat(preferences.time_format);
+    this.decideDateFormat(preferences.date_format);
   }
 
   private decideTimeFormat(timeFormat: TimeFormat) {
@@ -202,24 +195,13 @@
     }
   }
 
-  private loadRelative() {
-    return this.getPreferences().then(prefs => {
-      // prefs.relative_date_in_change_table is not set when false.
-      this.relative =
-        this.forceRelative || !!(prefs && prefs.relative_date_in_change_table);
-    });
+  private async loadRelative() {
+    const prefs = await this.restApiService.getPreferences();
+    this.relative =
+      this.forceRelative || Boolean(prefs?.relative_date_in_change_table);
   }
 
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
-  }
-
-  private getPreferences() {
-    return this.restApiService.getPreferences();
-  }
-
-  // private but used by tests
-  _computeDateStr() {
+  private computeDateStr() {
     if (!this.dateStr || !this.timeFormat || !this.dateFormat) {
       return '';
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
deleted file mode 100644
index 96e42e5..0000000
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
+++ /dev/null
@@ -1,449 +0,0 @@
-/**
- * @license
- * Copyright 2015 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup-karma.js';
-import './gr-date-formatter.js';
-import {parseDate} from '../../../utils/date-util.js';
-import {fixture, html} from '@open-wc/testing-helpers';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicTemplate = html`
-  <gr-date-formatter withTooltip dateStr="2015-09-24 23:30:17.033000000">
-  </gr-date-formatter>
-`;
-
-const lightTemplate = html`
-  <gr-date-formatter dateStr="2015-09-24 23:30:17.033000000">
-  </gr-date-formatter>
-`;
-
-suite('gr-date-formatter tests', () => {
-  let element;
-
-  setup(() => {
-  });
-
-  /**
-   * Parse server-formatter date and normalize into current timezone.
-   */
-  function normalizedDate(dateStr) {
-    const d = parseDate(dateStr);
-    d.setMinutes(d.getMinutes() + d.getTimezoneOffset());
-    return d;
-  }
-
-  async function testDates(nowStr, dateStr, expected, expectedWithDateAndTime,
-      expectedTooltip) {
-    // Normalize and convert the date to mimic server response.
-    dateStr = normalizedDate(dateStr)
-        .toJSON()
-        .replace('T', ' ')
-        .slice(0, -1);
-    sinon.useFakeTimers(normalizedDate(nowStr).getTime());
-    element.dateStr = dateStr;
-    await element.updateComplete;
-    const span = element.shadowRoot.querySelector('span');
-    const tooltip = element.shadowRoot.querySelector('gr-tooltip-content');
-    assert.equal(span.textContent.trim(), expected);
-    assert.equal(tooltip.title, expectedTooltip);
-    element.showDateAndTime = true;
-    await element.updateComplete;
-    assert.equal(span.textContent.trim(), expectedWithDateAndTime);
-  }
-
-  function stubRestAPI(preferences) {
-    const loggedInPromise = Promise.resolve(preferences !== null);
-    const preferencesPromise = Promise.resolve(preferences);
-    stubRestApi('getLoggedIn').returns(loggedInPromise);
-    stubRestApi('getPreferences').returns(preferencesPromise);
-    return Promise.all([loggedInPromise, preferencesPromise]);
-  }
-
-  suite('STD + 24 hours time format preference', () => {
-    setup(async () => {
-      await stubRestAPI({
-        time_format: 'HHMM_24',
-        date_format: 'STD',
-        relative_date_in_change_table: false,
-      });
-
-      element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element._loadPreferences();
-    });
-
-    test('invalid dates are quietly rejected', () => {
-      assert.notOk((new Date('foo')).valueOf());
-      element.dateStr = 'foo';
-      element.timeFormat = 'h:mm A';
-      assert.equal(element._computeDateStr(), '');
-    });
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '15:34',
-          '15:34',
-          'Jul 29, 2015, 15:34:14');
-    });
-
-    test('Within 24 hours on different days', async () => {
-      await testDates('2015-07-29 03:34:14.985000000',
-          '2015-07-28 20:25:14.985000000',
-          'Jul 28',
-          'Jul 28 20:25',
-          'Jul 28, 2015, 20:25:14');
-    });
-
-    test('More than 24 hours but less than six months', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-06-15 03:25:14.985000000',
-          'Jun 15',
-          'Jun 15 03:25',
-          'Jun 15, 2015, 03:25:14');
-    });
-
-    test('More than six months', async () => {
-      await testDates('2015-09-15 20:34:00.000000000',
-          '2015-01-15 03:25:00.000000000',
-          'Jan 15, 2015',
-          'Jan 15, 2015 03:25',
-          'Jan 15, 2015, 03:25:00');
-    });
-  });
-
-  suite('US + 24 hours time format preference', () => {
-    setup(async () => {
-      await stubRestAPI({
-        time_format: 'HHMM_24',
-        date_format: 'US',
-        relative_date_in_change_table: false,
-      });
-      element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element._loadPreferences();
-    });
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '15:34',
-          '15:34',
-          '07/29/15, 15:34:14');
-    });
-
-    test('Within 24 hours on different days', async () => {
-      await testDates('2015-07-29 03:34:14.985000000',
-          '2015-07-28 20:25:14.985000000',
-          '07/28',
-          '07/28 20:25',
-          '07/28/15, 20:25:14');
-    });
-
-    test('More than 24 hours but less than six months', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-06-15 03:25:14.985000000',
-          '06/15',
-          '06/15 03:25',
-          '06/15/15, 03:25:14');
-    });
-  });
-
-  suite('ISO + 24 hours time format preference', () => {
-    setup(async () => {
-      await stubRestAPI({
-        time_format: 'HHMM_24',
-        date_format: 'ISO',
-        relative_date_in_change_table: false,
-      });
-
-      element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element._loadPreferences();
-    });
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '15:34',
-          '15:34',
-          '2015-07-29, 15:34:14');
-    });
-
-    test('Within 24 hours on different days', async () => {
-      await testDates('2015-07-29 03:34:14.985000000',
-          '2015-07-28 20:25:14.985000000',
-          '07-28',
-          '07-28 20:25',
-          '2015-07-28, 20:25:14');
-    });
-
-    test('More than 24 hours but less than six months', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-06-15 03:25:14.985000000',
-          '06-15',
-          '06-15 03:25',
-          '2015-06-15, 03:25:14');
-    });
-  });
-
-  suite('EURO + 24 hours time format preference', () => {
-    setup(async () => {
-      await stubRestAPI({
-        time_format: 'HHMM_24',
-        date_format: 'EURO',
-        relative_date_in_change_table: false,
-      });
-
-      element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element._loadPreferences();
-    });
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '15:34',
-          '15:34',
-          '29.07.2015, 15:34:14');
-    });
-
-    test('Within 24 hours on different days', async () => {
-      await testDates('2015-07-29 03:34:14.985000000',
-          '2015-07-28 20:25:14.985000000',
-          '28. Jul',
-          '28. Jul 20:25',
-          '28.07.2015, 20:25:14');
-    });
-
-    test('More than 24 hours but less than six months', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-06-15 03:25:14.985000000',
-          '15. Jun',
-          '15. Jun 03:25',
-          '15.06.2015, 03:25:14');
-    });
-  });
-
-  suite('UK + 24 hours time format preference', () => {
-    setup(async () => {
-      stubRestAPI({
-        time_format: 'HHMM_24',
-        date_format: 'UK',
-        relative_date_in_change_table: false,
-      });
-
-      element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element._loadPreferences();
-    });
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '15:34',
-          '15:34',
-          '29/07/2015, 15:34:14');
-    });
-
-    test('Within 24 hours on different days', async () => {
-      await testDates('2015-07-29 03:34:14.985000000',
-          '2015-07-28 20:25:14.985000000',
-          '28/07',
-          '28/07 20:25',
-          '28/07/2015, 20:25:14');
-    });
-
-    test('More than 24 hours but less than six months', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-06-15 03:25:14.985000000',
-          '15/06',
-          '15/06 03:25',
-          '15/06/2015, 03:25:14');
-    });
-  });
-
-  suite('STD + 12 hours time format preference', () => {
-    setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      await stubRestAPI({time_format: 'HHMM_12', date_format: 'STD'});
-      element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element._loadPreferences();
-    });
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '3:34 PM',
-          '3:34 PM',
-          'Jul 29, 2015, 3:34:14 PM');
-    });
-  });
-
-  suite('US + 12 hours time format preference', () => {
-    setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      await stubRestAPI({time_format: 'HHMM_12', date_format: 'US'});
-      element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element._loadPreferences();
-    });
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '3:34 PM',
-          '3:34 PM',
-          '07/29/15, 3:34:14 PM');
-    });
-  });
-
-  suite('ISO + 12 hours time format preference', () => {
-    setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      await stubRestAPI({time_format: 'HHMM_12', date_format: 'ISO'});
-      element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element._loadPreferences();
-    });
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '3:34 PM',
-          '3:34 PM',
-          '2015-07-29, 3:34:14 PM');
-    });
-  });
-
-  suite('EURO + 12 hours time format preference', () => {
-    setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      await stubRestAPI({time_format: 'HHMM_12', date_format: 'EURO'});
-      element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element._loadPreferences();
-    });
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '3:34 PM',
-          '3:34 PM',
-          '29.07.2015, 3:34:14 PM');
-    });
-  });
-
-  suite('UK + 12 hours time format preference', () => {
-    setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI({time_format: 'HHMM_12', date_format: 'UK'});
-      element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element._loadPreferences();
-    });
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '3:34 PM',
-          '3:34 PM',
-          '29/07/2015, 3:34:14 PM');
-    });
-  });
-
-  suite('relative date preference', () => {
-    setup(async () => {
-      stubRestAPI({
-        time_format: 'HHMM_12',
-        date_format: 'STD',
-        relative_date_in_change_table: true,
-      });
-      element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    });
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '5 hours ago',
-          '5 hours ago',
-          'Jul 29, 2015, 3:34:14 PM');
-    });
-
-    test('More than six months', async () => {
-      await testDates('2015-09-15 20:34:00.000000000',
-          '2015-01-15 03:25:00.000000000',
-          '8 months ago',
-          '8 months ago',
-          'Jan 15, 2015, 3:25:00 AM');
-    });
-  });
-
-  suite('logged in', () => {
-    setup(async () => {
-      await stubRestAPI({
-        time_format: 'HHMM_12',
-        date_format: 'US',
-        relative_date_in_change_table: true,
-      });
-      element = await fixture(basicTemplate);
-      await element._loadPreferences();
-    });
-
-    test('Preferences are respected', () => {
-      assert.equal(element.timeFormat, 'h:mm A');
-      assert.equal(element.dateFormat.short, 'MM/DD');
-      assert.equal(element.dateFormat.full, 'MM/DD/YY');
-      assert.isTrue(element.relative);
-    });
-  });
-
-  suite('logged out', () => {
-    setup(async () => {
-      await stubRestAPI(null);
-      element = await fixture(basicTemplate);
-      await element._loadPreferences();
-    });
-
-    test('Default preferences are respected', () => {
-      assert.equal(element.timeFormat, 'HH:mm');
-      assert.equal(element.dateFormat.short, 'MMM DD');
-      assert.equal(element.dateFormat.full, 'MMM DD, YYYY');
-      assert.isFalse(element.relative);
-    });
-  });
-
-  suite('with tooltip', () => {
-    setup(async () => {
-      await stubRestAPI(null);
-      element = await fixture(basicTemplate);
-      await element._loadPreferences();
-      await element.updateComplete;
-    });
-
-    test('Tooltip is present', () => {
-      const tooltip = element.shadowRoot.querySelector('gr-tooltip-content');
-      assert.isOk(tooltip);
-    });
-  });
-
-  suite('without tooltip', () => {
-    setup(async () => {
-      await stubRestAPI(null);
-      element = await fixture(lightTemplate);
-      await element._loadPreferences();
-      await element.updateComplete;
-    });
-
-    test('Tooltip is absent', () => {
-      const tooltip = element.shadowRoot.querySelector('gr-tooltip-content');
-      assert.isNotOk(tooltip);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
new file mode 100644
index 0000000..54518fe
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
@@ -0,0 +1,529 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import './gr-date-formatter';
+import {GrDateFormatter} from './gr-date-formatter';
+import {parseDate} from '../../../utils/date-util';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {GrTooltipContent} from '../gr-tooltip-content/gr-tooltip-content';
+import {Timestamp} from '../../../api/rest-api';
+import {PreferencesInfo} from '../../../types/common';
+import {createPreferences} from '../../../test/test-data-generators';
+import {
+  createDefaultPreferences,
+  DateFormat,
+  TimeFormat,
+} from '../../../constants/constants';
+
+const basicTemplate = html`
+  <gr-date-formatter withTooltip dateStr="2015-09-24 23:30:17.033000000">
+  </gr-date-formatter>
+`;
+
+const lightTemplate = html`
+  <gr-date-formatter dateStr="2015-09-24 23:30:17.033000000">
+  </gr-date-formatter>
+`;
+
+suite('gr-date-formatter tests', () => {
+  let element: GrDateFormatter;
+
+  /**
+   * Parse server-formatted date and normalize into current timezone.
+   */
+  function normalizedDate(dateStr: Timestamp) {
+    const d = parseDate(dateStr);
+    d.setMinutes(d.getMinutes() + d.getTimezoneOffset());
+    return d;
+  }
+
+  async function testDates(
+    nowStr: string,
+    dateStr: string,
+    expected: string,
+    expectedWithDateAndTime: string,
+    expectedTooltip: string
+  ) {
+    // Normalize and convert the date to mimic server response.
+    const normalizedDateStr = normalizedDate(dateStr as Timestamp)
+      .toJSON()
+      .replace('T', ' ')
+      .slice(0, -1);
+    sinon.useFakeTimers(normalizedDate(nowStr as Timestamp).getTime());
+    element.dateStr = normalizedDateStr;
+    await element.updateComplete;
+    const span = queryAndAssert<HTMLSpanElement>(element, 'span');
+    const tooltip = queryAndAssert<GrTooltipContent>(
+      element,
+      'gr-tooltip-content'
+    );
+    assert.equal(span.textContent?.trim(), expected);
+    assert.equal(tooltip.title, expectedTooltip);
+    element.showDateAndTime = true;
+    await element.updateComplete;
+    assert.equal(span.textContent?.trim(), expectedWithDateAndTime);
+  }
+
+  function stubRestAPI(preferences?: PreferencesInfo) {
+    stubRestApi('getLoggedIn').resolves(preferences !== undefined);
+    stubRestApi('getPreferences').resolves(preferences);
+  }
+
+  suite('STD + 24 hours time format preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_24,
+        date_format: DateFormat.STD,
+        relative_date_in_change_table: false,
+      });
+
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '15:34',
+        '15:34',
+        'Jul 29, 2015, 15:34:14'
+      );
+    });
+
+    test('Within 24 hours on different days', async () => {
+      await testDates(
+        '2015-07-29 03:34:14.985000000',
+        '2015-07-28 20:25:14.985000000',
+        'Jul 28',
+        'Jul 28 20:25',
+        'Jul 28, 2015, 20:25:14'
+      );
+    });
+
+    test('More than 24 hours but less than six months', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-06-15 03:25:14.985000000',
+        'Jun 15',
+        'Jun 15 03:25',
+        'Jun 15, 2015, 03:25:14'
+      );
+    });
+
+    test('More than six months', async () => {
+      await testDates(
+        '2015-09-15 20:34:00.000000000',
+        '2015-01-15 03:25:00.000000000',
+        'Jan 15, 2015',
+        'Jan 15, 2015 03:25',
+        'Jan 15, 2015, 03:25:00'
+      );
+    });
+  });
+
+  suite('US + 24 hours time format preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_24,
+        date_format: DateFormat.US,
+        relative_date_in_change_table: false,
+      });
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '15:34',
+        '15:34',
+        '07/29/15, 15:34:14'
+      );
+    });
+
+    test('Within 24 hours on different days', async () => {
+      await testDates(
+        '2015-07-29 03:34:14.985000000',
+        '2015-07-28 20:25:14.985000000',
+        '07/28',
+        '07/28 20:25',
+        '07/28/15, 20:25:14'
+      );
+    });
+
+    test('More than 24 hours but less than six months', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-06-15 03:25:14.985000000',
+        '06/15',
+        '06/15 03:25',
+        '06/15/15, 03:25:14'
+      );
+    });
+  });
+
+  suite('ISO + 24 hours time format preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_24,
+        date_format: DateFormat.ISO,
+        relative_date_in_change_table: false,
+      });
+
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '15:34',
+        '15:34',
+        '2015-07-29, 15:34:14'
+      );
+    });
+
+    test('Within 24 hours on different days', async () => {
+      await testDates(
+        '2015-07-29 03:34:14.985000000',
+        '2015-07-28 20:25:14.985000000',
+        '07-28',
+        '07-28 20:25',
+        '2015-07-28, 20:25:14'
+      );
+    });
+
+    test('More than 24 hours but less than six months', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-06-15 03:25:14.985000000',
+        '06-15',
+        '06-15 03:25',
+        '2015-06-15, 03:25:14'
+      );
+    });
+  });
+
+  suite('EURO + 24 hours time format preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_24,
+        date_format: DateFormat.EURO,
+        relative_date_in_change_table: false,
+      });
+
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '15:34',
+        '15:34',
+        '29.07.2015, 15:34:14'
+      );
+    });
+
+    test('Within 24 hours on different days', async () => {
+      await testDates(
+        '2015-07-29 03:34:14.985000000',
+        '2015-07-28 20:25:14.985000000',
+        '28. Jul',
+        '28. Jul 20:25',
+        '28.07.2015, 20:25:14'
+      );
+    });
+
+    test('More than 24 hours but less than six months', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-06-15 03:25:14.985000000',
+        '15. Jun',
+        '15. Jun 03:25',
+        '15.06.2015, 03:25:14'
+      );
+    });
+  });
+
+  suite('UK + 24 hours time format preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_24,
+        date_format: DateFormat.UK,
+        relative_date_in_change_table: false,
+      });
+
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '15:34',
+        '15:34',
+        '29/07/2015, 15:34:14'
+      );
+    });
+
+    test('Within 24 hours on different days', async () => {
+      await testDates(
+        '2015-07-29 03:34:14.985000000',
+        '2015-07-28 20:25:14.985000000',
+        '28/07',
+        '28/07 20:25',
+        '28/07/2015, 20:25:14'
+      );
+    });
+
+    test('More than 24 hours but less than six months', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-06-15 03:25:14.985000000',
+        '15/06',
+        '15/06 03:25',
+        '15/06/2015, 03:25:14'
+      );
+    });
+  });
+
+  suite('STD + 12 hours time format preference', () => {
+    setup(async () => {
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.STD,
+      });
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '3:34 PM',
+        '3:34 PM',
+        'Jul 29, 2015, 3:34:14 PM'
+      );
+    });
+  });
+
+  suite('US + 12 hours time format preference', () => {
+    setup(async () => {
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.US,
+      });
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '3:34 PM',
+        '3:34 PM',
+        '07/29/15, 3:34:14 PM'
+      );
+    });
+  });
+
+  suite('ISO + 12 hours time format preference', () => {
+    setup(async () => {
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.ISO,
+      });
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '3:34 PM',
+        '3:34 PM',
+        '2015-07-29, 3:34:14 PM'
+      );
+    });
+  });
+
+  suite('EURO + 12 hours time format preference', () => {
+    setup(async () => {
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.EURO,
+      });
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '3:34 PM',
+        '3:34 PM',
+        '29.07.2015, 3:34:14 PM'
+      );
+    });
+  });
+
+  suite('UK + 12 hours time format preference', () => {
+    setup(async () => {
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.UK,
+      });
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '3:34 PM',
+        '3:34 PM',
+        '29/07/2015, 3:34:14 PM'
+      );
+    });
+  });
+
+  suite('relative date preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.STD,
+        relative_date_in_change_table: true,
+      });
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '5 hours ago',
+        '5 hours ago',
+        'Jul 29, 2015, 3:34:14 PM'
+      );
+    });
+
+    test('More than six months', async () => {
+      await testDates(
+        '2015-09-15 20:34:00.000000000',
+        '2015-01-15 03:25:00.000000000',
+        '8 months ago',
+        '8 months ago',
+        'Jan 15, 2015, 3:25:00 AM'
+      );
+    });
+  });
+
+  suite('logged in', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.US,
+        relative_date_in_change_table: true,
+      });
+      element = await fixture(basicTemplate);
+      await element.loadPreferences();
+    });
+
+    test('Preferences are respected', () => {
+      assert.equal(element.timeFormat, 'h:mm A');
+      assert.equal(element.dateFormat?.short, 'MM/DD');
+      assert.equal(element.dateFormat?.full, 'MM/DD/YY');
+      assert.isTrue(element.relative);
+    });
+  });
+
+  suite('logged out', () => {
+    setup(async () => {
+      stubRestAPI(undefined);
+      element = await fixture(basicTemplate);
+      await element.loadPreferences();
+    });
+
+    test('Default preferences are respected', () => {
+      assert.equal(element.timeFormat, 'HH:mm');
+      assert.equal(element.dateFormat?.short, 'MMM DD');
+      assert.equal(element.dateFormat?.full, 'MMM DD, YYYY');
+      assert.isFalse(element.relative);
+    });
+  });
+
+  suite('with tooltip', () => {
+    setup(async () => {
+      stubRestAPI(createDefaultPreferences());
+      element = await fixture(basicTemplate);
+      await element.loadPreferences();
+      await element.updateComplete;
+    });
+
+    test('Tooltip is present', () => {
+      const tooltip = queryAndAssert<GrTooltipContent>(
+        element,
+        'gr-tooltip-content'
+      );
+      assert.isOk(tooltip);
+    });
+  });
+
+  suite('without tooltip', () => {
+    setup(async () => {
+      stubRestAPI(createDefaultPreferences());
+      element = await fixture(lightTemplate);
+      await element.loadPreferences();
+      await element.updateComplete;
+    });
+
+    test('Tooltip is absent', () => {
+      const tooltip = query<GrTooltipContent>(element, 'gr-tooltip-content');
+      assert.isNotOk(tooltip);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
index 0663780..d05ab44 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
@@ -48,8 +48,6 @@
   ];
   const SELECTED_SCHEME = 'http';
 
-  setup(() => {});
-
   suite('unauthenticated', () => {
     setup(async () => {
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
@@ -60,6 +58,48 @@
       await element.updateComplete;
     });
 
+    test('render', () => {
+      expect(element).shadowDom.to.equal(/* HTML */ `
+        <div class="schemes">
+          <paper-tabs dir="null" id="downloadTabs" role="tablist" tabindex="0">
+            <paper-tab
+              aria-disabled="false"
+              aria-selected="true"
+              class="iron-selected"
+              data-scheme="http"
+              role="tab"
+              tabindex="0"
+            >
+              http
+            </paper-tab>
+            <paper-tab
+              aria-disabled="false"
+              aria-selected="false"
+              data-scheme="repo"
+              role="tab"
+              tabindex="-1"
+            >
+              repo
+            </paper-tab>
+            <paper-tab
+              aria-disabled="false"
+              aria-selected="false"
+              data-scheme="ssh"
+              role="tab"
+              tabindex="-1"
+            >
+              ssh
+            </paper-tab>
+          </paper-tabs>
+        </div>
+        <div class="commands"></div>
+        <gr-shell-command class="_label_checkout"> </gr-shell-command>
+        <gr-shell-command class="_label_cherrypick"> </gr-shell-command>
+        <gr-shell-command class="_label_formatpatch"> </gr-shell-command>
+        <gr-shell-command class="_label_pull"> </gr-shell-command>
+      `);
+    });
+
     test('focusOnCopy', async () => {
       const focusStub = sinon.stub(
         queryAndAssert<GrShellCommand>(element, 'gr-shell-command'),
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
index 10fe73e..76770b1 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
@@ -22,6 +22,116 @@
     );
   });
 
+  test('render', async () => {
+    element.value = '2';
+    element.items = [
+      {
+        value: 1,
+        text: 'Top Text 1',
+      },
+      {
+        value: 2,
+        bottomText: 'Bottom Text 2',
+        triggerText: 'Button Text 2',
+        text: 'Top Text 2',
+        mobileText: 'Mobile Text 2',
+      },
+      {
+        value: 3,
+        disabled: true,
+        bottomText: 'Bottom Text 3',
+        triggerText: 'Button Text 3',
+        date: '2017-08-18 23:11:42.569000000' as Timestamp,
+        text: 'Top Text 3',
+        mobileText: 'Mobile Text 3',
+      },
+    ];
+    await element.updateComplete;
+
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-button
+        aria-disabled="false"
+        class="dropdown-trigger"
+        down-arrow=""
+        id="trigger"
+        link=""
+        no-uppercase=""
+        role="button"
+        slot="dropdown-trigger"
+        tabindex="0"
+      >
+        <span id="triggerText"> Button Text 2 </span>
+        <gr-copy-clipboard hidden="" hideinput=""> </gr-copy-clipboard>
+      </gr-button>
+      <iron-dropdown
+        aria-disabled="false"
+        aria-hidden="true"
+        horizontal-align="left"
+        id="dropdown"
+        style="outline: none; display: none;"
+        vertical-align="top"
+      >
+        <paper-listbox
+          class="dropdown-content"
+          role="listbox"
+          slot="dropdown-content"
+          tabindex="0"
+        >
+          <paper-item
+            aria-disabled="false"
+            aria-selected="false"
+            data-value="1"
+            role="option"
+            tabindex="-1"
+          >
+            <div class="topContent">
+              <div>Top Text 1</div>
+            </div>
+          </paper-item>
+          <paper-item
+            aria-disabled="false"
+            aria-selected="true"
+            class="iron-selected"
+            data-value="2"
+            role="option"
+            tabindex="0"
+          >
+            <div class="topContent">
+              <div>Top Text 2</div>
+            </div>
+            <div class="bottomContent">
+              <div>Bottom Text 2</div>
+            </div>
+          </paper-item>
+          <paper-item
+            aria-disabled="true"
+            aria-selected="false"
+            data-value="3"
+            disabled=""
+            role="option"
+            style="pointer-events: none;"
+            tabindex="-1"
+          >
+            <div class="topContent">
+              <div>Top Text 3</div>
+              <gr-date-formatter> </gr-date-formatter>
+            </div>
+            <div class="bottomContent">
+              <div>Bottom Text 3</div>
+            </div>
+          </paper-item>
+        </paper-listbox>
+      </iron-dropdown>
+      <gr-select>
+        <select>
+          <option value="1">Top Text 1</option>
+          <option value="2">Mobile Text 2</option>
+          <option disabled="" value="3">Mobile Text 3</option>
+        </select>
+      </gr-select>
+    `);
+  });
+
   test('hide copy by default', () => {
     const copyEl = query<HTMLElement>(
       element,
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
index 5fabee7..bc47945 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
@@ -145,7 +145,7 @@
     assert.isFalse(tooltipContents[1].hasTooltip);
   });
 
-  test('shadowDom', async () => {
+  test('render', async () => {
     element.items = [
       {name: 'item one', id: 'foo', tooltip: 'hello'},
       {name: 'item two', id: 'bar', url: 'http://bar'},
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 88106fa..a3497df 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -107,13 +107,13 @@
         }
         code {
           display: block;
-          white-space: pre-wrap;
+          white-space: nowrap;
           background-color: var(--background-color-secondary);
           border: 1px solid var(--border-color);
           border-left-width: var(--spacing-s);
           margin: var(--spacing-m) 0;
           padding: var(--spacing-s) var(--spacing-m);
-          overflow-x: scroll;
+          overflow-x: auto;
         }
         li {
           list-style-type: disc;
@@ -354,7 +354,7 @@
   }
 
   private renderLink(text: string, url: string): TemplateResult {
-    return html`<a href=${url}>${text}</a>`;
+    return html`<a target="_blank" href=${url}>${text}</a>`;
   }
 
   private renderInlineCode(text: string): TemplateResult {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index d601dd0..6810f99 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -78,6 +78,20 @@
     assert.lengthOf(element._computeBlocks(''), 0);
   });
 
+  test('render', async () => {
+    element.content = 'text `code`';
+    await element.updateComplete;
+
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <p>
+        <gr-linked-text content="text " inline="" pre="">
+          <span id="output" slot="insert"> text </span>
+        </gr-linked-text>
+        <span class="inline-code"> code </span>
+      </p>
+    `);
+  });
+
   for (const text of [
     'Para1',
     'Para 1\nStill para 1',
diff --git a/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts b/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts
new file mode 100644
index 0000000..13397b6
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {css, html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-icon': GrIcon;
+  }
+}
+/**
+ * @attr {String} icon - the icon to display
+ * @attr {Boolean} filled - whether the icon should be filled.
+ */
+@customElement('gr-icon')
+export class GrIcon extends LitElement {
+  @property({type: String, reflect: true})
+  icon?: string;
+
+  @property({type: Boolean, reflect: true})
+  filled?: boolean;
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          /* Fallback rule for color */
+          color: var(--deemphasized-text-color);
+          font-family: var(--icon-font-family, 'Material Symbols Outlined');
+          font-weight: normal;
+          font-style: normal;
+          font-size: 20px;
+          line-height: 1;
+          letter-spacing: normal;
+          text-transform: none;
+          display: inline-block;
+          white-space: nowrap;
+          word-wrap: normal;
+          direction: ltr;
+          font-variation-settings: 'FILL' 0;
+          vertical-align: top;
+        }
+        :host([filled]) {
+          font-variation-settings: 'FILL' 1;
+        }
+        /* This is the trick such that the name of the icon doesn't appear in
+         * search
+         */
+        :host::before {
+          content: attr(icon);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html``;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts
index e133a54..62fcae8 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts
@@ -19,6 +19,19 @@
     );
   });
 
+  test('render', async () => {
+    element.text = 'abc 123';
+    element.limit = 5;
+    element.tooltip = 'tip';
+    await element.updateComplete;
+
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-tooltip-content has-tooltip="" title="abc 123 (tip)">
+        abc …
+      </gr-tooltip-content>
+    `);
+  });
+
   test('tooltip without title input', async () => {
     element.text = 'abc 123';
     await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
index 27a2a3a..0d9673d 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
@@ -44,7 +44,7 @@
       },
       googlesearch: {
         match: 'google:(.+)',
-        link: 'https://bing.com/search?q=$1', // html should supercede link.
+        link: 'https://bing.com/search?q=$1', // html should supersede link.
         html: '<a href="https://google.com/search?q=$1">$1</a>',
       },
       hashedhtml: {
@@ -71,6 +71,25 @@
     window.CANONICAL_PATH = originalCanonicalPath;
   });
 
+  test('render', async () => {
+    element.content =
+      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    await element.updateComplete;
+    expect(element).lightDom.to.equal(/* HTML */ `
+      <div id="output"></div>
+      <span id="output" slot="insert">
+        <a
+          href="https://bugs.chromium.org/p/gerrit/issues/detail?id=3650"
+          rel="noopener"
+          style="color: var(--link-color)"
+          target="_blank"
+        >
+          https://bugs.chromium.org/p/gerrit/issues/detail?id=3650
+        </a>
+      </span>
+    `);
+  });
+
   test('URL pattern was parsed and linked.', async () => {
     // Regular inline link.
     const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
index f040554..2ceb5c9 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
@@ -21,6 +21,41 @@
     await element.updateComplete;
   });
 
+  test('render', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div id="topContainer">
+        <div class="filterContainer">
+          <label> Filter: </label>
+          <iron-input>
+            <input id="filter" type="text" />
+          </iron-input>
+        </div>
+        <div id="createNewContainer">
+          <gr-button
+            aria-disabled="false"
+            id="createNew"
+            link=""
+            primary=""
+            role="button"
+            tabindex="0"
+          >
+            Create New
+          </gr-button>
+        </div>
+      </div>
+      <slot> </slot>
+      <nav>
+        Page 1
+        <a hidden="" href="" id="prevArrow">
+          <span class="material-icon"> chevron_left </span>
+        </a>
+        <a hidden="" href=",25" id="nextArrow">
+          <span class="material-icon"> chevron_right </span>
+        </a>
+      </nav>
+    `);
+  });
+
   test('computeNavLink', () => {
     const offset = 25;
     const projectsPerPage = 25;
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts
index da7ac48..009e5ab 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts
@@ -21,6 +21,11 @@
     element = basicFixture.instantiate() as GrOverlay;
   });
 
+  test('render', async () => {
+    await element.open();
+    expect(element).shadowDom.to.equal(/* HTML */ ' <slot></slot> ');
+  });
+
   test('popstate listener is attached on open and removed on close', () => {
     const addEventListenerStub = sinon.stub(window, 'addEventListener');
     const removeEventListenerStub = sinon.stub(window, 'removeEventListener');
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.ts
index ee688ec..5f54989 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.ts
@@ -20,7 +20,14 @@
         </ul>
       </gr-page-nav>
     `);
-    await element.updateComplete;
+  });
+
+  test('render', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <nav aria-label="Sidebar">
+        <slot> </slot>
+      </nav>
+    `);
   });
 
   test('header is not pinned just below top', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts
index ba41af1..0119aa0 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts
@@ -19,6 +19,27 @@
     await element.updateComplete;
   });
 
+  test('render', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div>
+        <gr-labeled-autocomplete
+          id="repoInput"
+          label="Repository"
+          placeholder="Select repo"
+        >
+        </gr-labeled-autocomplete>
+        <span class="material-icon"> chevron_right </span>
+        <gr-labeled-autocomplete
+          disabled=""
+          id="branchInput"
+          label="Branch"
+          placeholder="Select branch"
+        >
+        </gr-labeled-autocomplete>
+      </div>
+    `);
+  });
+
   suite('getRepoSuggestions', () => {
     let getReposStub: sinon.SinonStub;
     setup(() => {
@@ -109,9 +130,8 @@
       const repo = 'gerrit' as RepoName;
       const branchInput = 'refs/heads/stable-2.1';
       element.repo = repo;
-      return element.getRepoBranchesSuggestions(branchInput).then(() => {
-        assert.isTrue(getRepoBranchesStub.calledWith('stable-2.1', repo, 15));
-      });
+      await element.getRepoBranchesSuggestions(branchInput);
+      assert.isTrue(getRepoBranchesStub.calledWith('stable-2.1', repo, 15));
     });
 
     test('does not query when repo is unset', async () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
index 808be4a..bc364e3 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
@@ -23,6 +23,10 @@
     `);
   });
 
+  test('render', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ '<slot></slot>');
+  });
+
   test('bindValue must be set to the first option value', () => {
     assert.equal(element.bindValue, '1');
     assert.equal(element.nativeSelect.value, '1');
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
index 6463b7a..9301662 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
@@ -21,6 +21,18 @@
     await flush();
   });
 
+  test('render', async () => {
+    element.label = 'label1';
+    await element.updateComplete;
+
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <label> label1 </label>
+      <div class="commandContainer">
+        <gr-copy-clipboard buttontitle="" hastooltip=""> </gr-copy-clipboard>
+      </div>
+    `);
+  });
+
   test('focusOnCopy', async () => {
     const focusStub = sinon.stub(
       queryAndAssert<GrCopyClipboard>(element, 'gr-copy-clipboard')!,
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index ba2d9cc..db8a94a 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -18,11 +18,12 @@
 import {addShortcut, Key} from '../../../utils/dom-util';
 import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 import {fire} from '../../../utils/event-util';
-import {LitElement, css, html} from 'lit';
+import {LitElement, css, html, nothing} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {PropertyValues} from 'lit';
 import {classMap} from 'lit/directives/class-map';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 const MAX_ITEMS_DROPDOWN = 10;
 
@@ -103,6 +104,8 @@
   // Accessed in tests.
   readonly reporting = getAppContext().reportingService;
 
+  private readonly flagsService = getAppContext().flagsService;
+
   private disableEnterKeyForSelectingSuggestion = false;
 
   /** Called in disconnectedCallback. */
@@ -207,16 +210,8 @@
       hiddenText in order to correctly position the dropdown. After being moved,
       it is set as the positionTarget for the emojiSuggestions dropdown. -->
       <span id="caratSpan"></span>
-      <gr-autocomplete-dropdown
-        id="emojiSuggestions"
-        .suggestions=${this.suggestions}
-        .horizontalOffset=${20}
-        .verticalOffset=${20}
-        vertical-align="top"
-        horizontal-align="left"
-        @dropdown-closed=${this.resetEmojiDropdown}
-        @item-selected=${this.handleEmojiSelect}
-      >
+      ${this.renderEmojiDropdown()}
+      ${this.renderReviewerDropdown()}
       </gr-autocomplete-dropdown>
       <iron-autogrow-textarea
         id="textarea"
@@ -235,6 +230,35 @@
     `;
   }
 
+  private renderEmojiDropdown() {
+    return html`
+      <gr-autocomplete-dropdown
+        id="emojiSuggestions"
+        .suggestions=${this.suggestions}
+        .horizontalOffset=${20}
+        .verticalOffset=${20}
+        vertical-align="top"
+        horizontal-align="left"
+        @dropdown-closed=${this.resetEmojiDropdown}
+        @item-selected=${this.handleEmojiSelect}
+      >
+      </gr-autocomplete-dropdown>
+    `;
+  }
+
+  private renderReviewerDropdown() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
+      return nothing;
+    return html` <gr-autocomplete-dropdown
+      id="reviewerSuggestions"
+      vertical-align="top"
+      horizontal-align="left"
+      .horizontalOffset=${20}
+      .verticalOffset=${20}
+      role="listbox"
+    ></gr-autocomplete-dropdown>`;
+  }
+
   override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('text')) {
       this.handleTextChanged(this.text);
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
index f8d98e8..a8a30b8 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -8,7 +8,7 @@
 import {GrTextarea} from './gr-textarea';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {ItemSelectedEvent} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
-import {waitUntil} from '../../../test/test-utils';
+import {stubFlags, waitUntil} from '../../../test/test-utils';
 import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-textarea tests', () => {
@@ -43,6 +43,48 @@
     );
   });
 
+  suite('mention users', () => {
+    setup(async () => {
+      stubFlags('isEnabled').returns(true);
+      element.requestUpdate();
+      await element.updateComplete;
+    });
+
+    test('renders', () => {
+      expect(element).shadowDom.to.equal(
+        /* HTML */ `
+          <div id="hiddenText"></div>
+          <span id="caratSpan"> </span>
+          <gr-autocomplete-dropdown
+            horizontal-align="left"
+            id="emojiSuggestions"
+            is-hidden=""
+            style="position: fixed; top: 478px; left: 321px; box-sizing: border-box; max-height: 956px; max-width: 642px;"
+            vertical-align="top"
+          >
+          </gr-autocomplete-dropdown>
+          <gr-autocomplete-dropdown
+            horizontal-align="left"
+            id="reviewerSuggestions"
+            is-hidden=""
+            role="listbox"
+            style="position: fixed; top: 478px; left: 321px; box-sizing: border-box; max-height: 956px; max-width: 642px;"
+            vertical-align="top"
+          >
+          </gr-autocomplete-dropdown>
+          <iron-autogrow-textarea aria-disabled="false" id="textarea">
+          </iron-autogrow-textarea>
+        `,
+        {
+          // gr-autocomplete-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [
+            {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
+          ],
+        }
+      );
+    });
+  });
+
   test('monospace is set properly', () => {
     assert.isFalse(element.classList.contains('monospace'));
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts
index dab1d98..e246be0 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts
@@ -36,6 +36,16 @@
     await element.updateComplete;
   });
 
+  test('render', async () => {
+    element.showIcon = true;
+    await element.updateComplete;
+
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <slot> </slot>
+      <span class="filled material-icon"> info </span>
+    `);
+  });
+
   test('icon is not visible by default', () => {
     assert.isNotOk(query(element, '.material-icon'));
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
index 71038eb..e1fb0237 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
@@ -18,6 +18,19 @@
     await element.updateComplete;
   });
 
+  test('render', async () => {
+    element.text = 'tooltipText';
+    await element.updateComplete;
+
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div class="tooltip">
+        <i class="arrow arrowPositionBelow" style="margin-left:0;"> </i>
+        tooltipText
+        <i class="arrow arrowPositionAbove" style="margin-left:0;"> </i>
+      </div>
+    `);
+  });
+
   test('max-width is respected if set', async () => {
     element.text =
       'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
index 207270c..eb99b32 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
@@ -472,7 +472,7 @@
       cells[signCols.right].classList.add('sign', 'right');
     }
     const moveRangeHeader = createElementDiff('gr-range-header');
-    moveRangeHeader.setAttribute('icon', 'gr-icons:move-item');
+    moveRangeHeader.setAttribute('icon', 'move_item');
     moveRangeHeader.appendChild(descriptionTextDiv);
     cells[descriptionIndex].classList.add('moveHeader');
     cells[descriptionIndex].appendChild(moveRangeHeader);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util_test.js b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util_test.js
deleted file mode 100644
index 9dc198a..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util_test.js
+++ /dev/null
@@ -1,160 +0,0 @@
-/**
- * @license
- * Copyright 2021 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup-karma.js';
-import {FrameConstrainer} from './util.js';
-
-suite('FrameConstrainer tests', () => {
-  let constrainer;
-
-  setup(() => {
-    constrainer = new FrameConstrainer();
-    constrainer.setBounds({width: 100, height: 100});
-    constrainer.setFrameSize({width: 50, height: 50});
-    constrainer.requestCenter({x: 50, y: 50});
-  });
-
-  suite('changing center', () => {
-    test('moves frame to requested position', () => {
-      constrainer.requestCenter({x: 30, y: 30});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 5, y: 5}, dimensions: {width: 50, height: 50}});
-    });
-
-    test('keeps frame in bounds for top left corner', () => {
-      constrainer.requestCenter({x: 5, y: 5});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 0, y: 0}, dimensions: {width: 50, height: 50}});
-    });
-
-    test('keeps frame in bounds for bottom right corner', () => {
-      constrainer.requestCenter({x: 95, y: 95});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
-    });
-
-    test('handles out-of-bounds center left', () => {
-      constrainer.requestCenter({x: -5, y: 50});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 0, y: 25}, dimensions: {width: 50, height: 50}});
-    });
-
-    test('handles out-of-bounds center right', () => {
-      constrainer.requestCenter({x: 105, y: 50});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 50, y: 25}, dimensions: {width: 50, height: 50}});
-    });
-
-    test('handles out-of-bounds center top', () => {
-      constrainer.requestCenter({x: 50, y: -5});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 25, y: 0}, dimensions: {width: 50, height: 50}});
-    });
-
-    test('handles out-of-bounds center bottom', () => {
-      constrainer.requestCenter({x: 50, y: 105});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 25, y: 50}, dimensions: {width: 50, height: 50}});
-    });
-  });
-
-  suite('changing frame size', () => {
-    test('maintains center when decreased', () => {
-      constrainer.setFrameSize({width: 10, height: 10});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 45, y: 45}, dimensions: {width: 10, height: 10}});
-    });
-
-    test('maintains center when increased', () => {
-      constrainer.setFrameSize({width: 80, height: 80});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 10, y: 10}, dimensions: {width: 80, height: 80}});
-    });
-
-    test('updates center to remain in bounds when increased', () => {
-      constrainer.setFrameSize({width: 10, height: 10});
-      constrainer.requestCenter({x: 95, y: 95});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 90, y: 90}, dimensions: {width: 10, height: 10}});
-
-      constrainer.setFrameSize({width: 20, height: 20});
-      assert.deepEqual(
-          constrainer.getUnscaledFrame(),
-          {origin: {x: 80, y: 80}, dimensions: {width: 20, height: 20}});
-    });
-  });
-
-  suite('changing scale', () => {
-    suite('for unscaled frame', () => {
-      test('adjusts origin to maintain center when zooming in', () => {
-        constrainer.setScale(2);
-        assert.deepEqual(
-            constrainer.getUnscaledFrame(),
-            {origin: {x: 75, y: 75}, dimensions: {width: 50, height: 50}});
-      });
-
-      test('adjusts origin to maintain center when zooming out', () => {
-        constrainer.setFrameSize({width: 20, height: 20});
-        constrainer.setScale(0.5);
-        assert.deepEqual(
-            constrainer.getUnscaledFrame(),
-            {origin: {x: 15, y: 15}, dimensions: {width: 20, height: 20}});
-      });
-
-      test('keeps frame in bounds when zooming out', () => {
-        constrainer.setScale(5);
-        constrainer.requestCenter({x: 100, y: 100});
-        assert.deepEqual(
-            constrainer.getUnscaledFrame(),
-            {origin: {x: 450, y: 450}, dimensions: {width: 50, height: 50}});
-
-        constrainer.setScale(1);
-        assert.deepEqual(
-            constrainer.getUnscaledFrame(),
-            {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
-      });
-    });
-
-    suite('for scaled frame', () => {
-      test('decreases frame size and maintains center when zooming in', () => {
-        constrainer.setScale(2);
-        assert.deepEqual(
-            constrainer.getScaledFrame(),
-            {origin: {x: 37.5, y: 37.5}, dimensions: {width: 25, height: 25}});
-      });
-
-      test('increases frame size and maintains center when zooming out', () => {
-        constrainer.setFrameSize({width: 20, height: 20});
-        constrainer.setScale(0.5);
-        assert.deepEqual(
-            constrainer.getScaledFrame(),
-            {origin: {x: 30, y: 30}, dimensions: {width: 40, height: 40}});
-      });
-
-      test('keeps frame in bounds when zooming out', () => {
-        constrainer.setScale(5);
-        constrainer.requestCenter({x: 100, y: 100});
-        assert.deepEqual(
-            constrainer.getScaledFrame(),
-            {origin: {x: 90, y: 90}, dimensions: {width: 10, height: 10}});
-
-        constrainer.setScale(1);
-        assert.deepEqual(
-            constrainer.getScaledFrame(),
-            {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
-      });
-    });
-  });
-});
\ No newline at end of file
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util_test.ts
new file mode 100644
index 0000000..2b8f754
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util_test.ts
@@ -0,0 +1,179 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import {FrameConstrainer} from './util';
+
+suite('FrameConstrainer tests', () => {
+  let constrainer: FrameConstrainer;
+
+  setup(() => {
+    constrainer = new FrameConstrainer();
+    constrainer.setBounds({width: 100, height: 100});
+    constrainer.setFrameSize({width: 50, height: 50});
+    constrainer.requestCenter({x: 50, y: 50});
+  });
+
+  suite('changing center', () => {
+    test('moves frame to requested position', () => {
+      constrainer.requestCenter({x: 30, y: 30});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 5, y: 5},
+        dimensions: {width: 50, height: 50},
+      });
+    });
+
+    test('keeps frame in bounds for top left corner', () => {
+      constrainer.requestCenter({x: 5, y: 5});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 0, y: 0},
+        dimensions: {width: 50, height: 50},
+      });
+    });
+
+    test('keeps frame in bounds for bottom right corner', () => {
+      constrainer.requestCenter({x: 95, y: 95});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 50, y: 50},
+        dimensions: {width: 50, height: 50},
+      });
+    });
+
+    test('handles out-of-bounds center left', () => {
+      constrainer.requestCenter({x: -5, y: 50});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 0, y: 25},
+        dimensions: {width: 50, height: 50},
+      });
+    });
+
+    test('handles out-of-bounds center right', () => {
+      constrainer.requestCenter({x: 105, y: 50});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 50, y: 25},
+        dimensions: {width: 50, height: 50},
+      });
+    });
+
+    test('handles out-of-bounds center top', () => {
+      constrainer.requestCenter({x: 50, y: -5});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 25, y: 0},
+        dimensions: {width: 50, height: 50},
+      });
+    });
+
+    test('handles out-of-bounds center bottom', () => {
+      constrainer.requestCenter({x: 50, y: 105});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 25, y: 50},
+        dimensions: {width: 50, height: 50},
+      });
+    });
+  });
+
+  suite('changing frame size', () => {
+    test('maintains center when decreased', () => {
+      constrainer.setFrameSize({width: 10, height: 10});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 45, y: 45},
+        dimensions: {width: 10, height: 10},
+      });
+    });
+
+    test('maintains center when increased', () => {
+      constrainer.setFrameSize({width: 80, height: 80});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 10, y: 10},
+        dimensions: {width: 80, height: 80},
+      });
+    });
+
+    test('updates center to remain in bounds when increased', () => {
+      constrainer.setFrameSize({width: 10, height: 10});
+      constrainer.requestCenter({x: 95, y: 95});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 90, y: 90},
+        dimensions: {width: 10, height: 10},
+      });
+
+      constrainer.setFrameSize({width: 20, height: 20});
+      assert.deepEqual(constrainer.getUnscaledFrame(), {
+        origin: {x: 80, y: 80},
+        dimensions: {width: 20, height: 20},
+      });
+    });
+  });
+
+  suite('changing scale', () => {
+    suite('for unscaled frame', () => {
+      test('adjusts origin to maintain center when zooming in', () => {
+        constrainer.setScale(2);
+        assert.deepEqual(constrainer.getUnscaledFrame(), {
+          origin: {x: 75, y: 75},
+          dimensions: {width: 50, height: 50},
+        });
+      });
+
+      test('adjusts origin to maintain center when zooming out', () => {
+        constrainer.setFrameSize({width: 20, height: 20});
+        constrainer.setScale(0.5);
+        assert.deepEqual(constrainer.getUnscaledFrame(), {
+          origin: {x: 15, y: 15},
+          dimensions: {width: 20, height: 20},
+        });
+      });
+
+      test('keeps frame in bounds when zooming out', () => {
+        constrainer.setScale(5);
+        constrainer.requestCenter({x: 100, y: 100});
+        assert.deepEqual(constrainer.getUnscaledFrame(), {
+          origin: {x: 450, y: 450},
+          dimensions: {width: 50, height: 50},
+        });
+
+        constrainer.setScale(1);
+        assert.deepEqual(constrainer.getUnscaledFrame(), {
+          origin: {x: 50, y: 50},
+          dimensions: {width: 50, height: 50},
+        });
+      });
+    });
+
+    suite('for scaled frame', () => {
+      test('decreases frame size and maintains center when zooming in', () => {
+        constrainer.setScale(2);
+        assert.deepEqual(constrainer.getScaledFrame(), {
+          origin: {x: 37.5, y: 37.5},
+          dimensions: {width: 25, height: 25},
+        });
+      });
+
+      test('increases frame size and maintains center when zooming out', () => {
+        constrainer.setFrameSize({width: 20, height: 20});
+        constrainer.setScale(0.5);
+        assert.deepEqual(constrainer.getScaledFrame(), {
+          origin: {x: 30, y: 30},
+          dimensions: {width: 40, height: 40},
+        });
+      });
+
+      test('keeps frame in bounds when zooming out', () => {
+        constrainer.setScale(5);
+        constrainer.requestCenter({x: 100, y: 100});
+        assert.deepEqual(constrainer.getScaledFrame(), {
+          origin: {x: 90, y: 90},
+          dimensions: {width: 10, height: 10},
+        });
+
+        constrainer.setScale(1);
+        assert.deepEqual(constrainer.getScaledFrame(), {
+          origin: {x: 50, y: 50},
+          dimensions: {width: 50, height: 50},
+        });
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index cc44f50..062347f 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
@@ -359,7 +359,7 @@
       ignoredWhitespaceOnly: !!chunk.common,
       keyLocation: !!chunk.keyLocation,
     };
-    if (chunk.skip) {
+    if (chunk.skip !== undefined) {
       return new GrDiffGroup({
         type,
         skip: chunk.skip,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
index 5e39dba..d99fd6a 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -488,6 +488,26 @@
       // group[4] is the displayed part of the second ab
     });
 
+    test('works with skip === 0', async () => {
+      element.context = 3;
+      const content = [
+        {
+          skip: 0,
+        },
+        {
+          b: [
+            '/**',
+            ' * @license',
+            ' * Copyright 2015 Google LLC',
+            ' * SPDX-License-Identifier: Apache-2.0',
+            ' */',
+            "import '../../../test/common-test-setup-karma';",
+          ],
+        },
+      ];
+      await element.process(content, false);
+    });
+
     test('break up common diff chunks', () => {
       element.keyLocations = {
         left: {1: true},
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index 7cb87ab..eac76da 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -254,7 +254,7 @@
           throw new Error('Cannot set skip and lines');
         }
         this.skip = options.skip;
-        if (options.skip) {
+        if (options.skip !== undefined) {
           this.lineRange = {
             left: {
               start_line: options.offsetLeft,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index cb7d2db..86d4730 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -68,6 +68,7 @@
 import {grSyntaxTheme} from '../gr-syntax-themes/gr-syntax-theme';
 import {grRangedCommentTheme} from '../gr-ranged-comment-themes/gr-ranged-comment-theme';
 import {classMap} from 'lit/directives/class-map';
+import {iconStyles} from '../../../styles/gr-icon-styles';
 
 const NO_NEWLINE_LEFT = 'No newline at end of left file.';
 const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
@@ -268,6 +269,7 @@
 
   static override get styles() {
     return [
+      iconStyles,
       sharedStyles,
       grSyntaxTheme,
       grRangedCommentTheme,
@@ -668,10 +670,10 @@
           --divider-height: var(--spacing-s);
           --divider-border: 1px;
         }
+        /* TODO: Is this still used? */
         .contextControl gr-button iron-icon {
           /* should match line-height of gr-button */
-          width: var(--line-height-mono, 18px);
-          height: var(--line-height-mono, 18px);
+          font-size: var(--line-height-mono, 18px);
         }
         .contextControl td:not(.lineNumButton) {
           text-align: center;
@@ -790,7 +792,7 @@
           font-size: var(--font-size-normal, 14px);
           line-height: var(--line-height-normal);
         }
-        td.lost iron-icon {
+        td.lost .material-icon {
           padding: 0 var(--spacing-s) 0 var(--spacing-m);
           color: var(--blue-700);
         }
@@ -1733,8 +1735,9 @@
 
   private portedCommentsWithoutRangeMessage() {
     const div = document.createElement('div');
-    const icon = document.createElement('iron-icon');
-    icon.setAttribute('icon', 'gr-icons:info-outline');
+    const icon = document.createElement('span');
+    icon.className = 'material-icon';
+    icon.innerText = 'info';
     div.appendChild(icon);
     const span = document.createElement('span');
     span.innerText = 'Original comment position not found in this patchset';
diff --git a/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts b/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts
index c80a459..f386a47 100644
--- a/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts
+++ b/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts
@@ -5,7 +5,7 @@
  */
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
-import '@polymer/iron-icon/iron-icon';
+import {iconStyles} from '../../../styles/gr-icon-styles';
 
 /**
  * Represents a header (label) for a code chunk whenever showing
@@ -18,8 +18,12 @@
   @property({type: String})
   icon?: string;
 
+  @property({type: Boolean})
+  filled?: boolean;
+
   static override get styles() {
     return [
+      iconStyles,
       css`
         .row {
           color: var(--gr-range-header-color);
@@ -33,8 +37,7 @@
         }
         .icon {
           color: var(--gr-range-header-color);
-          height: var(--line-height-small, 16px);
-          width: var(--line-height-small, 16px);
+          font-size: var(--line-height-small, 16px);
           margin-right: var(--spacing-s);
         }
       `,
@@ -44,7 +47,11 @@
   override render() {
     const icon = this.icon ?? '';
     return html` <div class="row">
-      <iron-icon class="icon" .icon=${icon} aria-hidden="true"></iron-icon>
+      <span
+        class="icon material-icon ${this.filled ? 'filled' : ''}"
+        aria-hidden="true"
+        >${icon}</span
+      >
       <slot></slot>
     </div>`;
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
index 235da89..35d496a 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
@@ -33,7 +33,7 @@
 
   override render() {
     return html`<div class="rangeHighlight row">
-      <gr-range-header icon="gr-icons:comment"
+      <gr-range-header icon="mode_comment" filled
         >${this._computeRangeLabel(this.range)}</gr-range-header
       >
     </div>`;
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index f38e060..8f692a8 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -36,7 +36,7 @@
     case LinkIcon.HELP_PAGE:
       return {name: 'help'};
     case LinkIcon.REPORT_BUG:
-      return {name: 'bug', filled: true};
+      return {name: 'bug_report', filled: true};
     case LinkIcon.CODE:
       return {name: 'code'};
     case LinkIcon.FILE_PRESENT:
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 4a001ad..e2318e8 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -21,4 +21,5 @@
   MORE_FILES_INFO = 'UiFeature__more_files_info',
   PUSH_NOTIFICATIONS = 'UiFeature__push_notifications',
   RELATED_CHANGES_SUBMITTABILITY = 'UiFeature__related_changes_submittability',
+  MENTION_USERS = 'UIFeature_mention_users',
 }
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 95a3ddc..6eb779f 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -377,11 +377,15 @@
     }
     if (type !== ERROR.TYPE) {
       if (value !== undefined) {
-        console.debug(`Reporting: ${name}: ${value}`);
+        console.debug(
+          `Reporting(${new Date().toISOString()}): ${name}: ${value}`
+        );
       } else if (eventDetails !== undefined) {
-        console.debug(`Reporting: ${name}: ${eventDetails}`);
+        console.debug(
+          `Reporting(${new Date().toISOString()}): ${name}: ${eventDetails}`
+        );
       } else {
-        console.debug(`Reporting: ${name}`);
+        console.debug(`Reporting(${new Date().toISOString()}): ${name}`);
       }
     }
   }
diff --git a/polygerrit-ui/app/styles/gr-submit-requirements-styles.ts b/polygerrit-ui/app/styles/gr-submit-requirements-styles.ts
index c35d8ed..948e9b0 100644
--- a/polygerrit-ui/app/styles/gr-submit-requirements-styles.ts
+++ b/polygerrit-ui/app/styles/gr-submit-requirements-styles.ts
@@ -6,15 +6,15 @@
 import {css} from 'lit';
 
 export const submitRequirementsStyles = css`
-  .material-icon.check_circle,
-  .material-icon.published_with_changes {
+  gr-icon.check_circle,
+  gr-icon.published_with_changes {
     color: var(--success-foreground);
   }
-  .material-icon.block,
-  .material-icon.error {
+  gr-icon.block,
+  gr-icon.error {
     color: var(--deemphasized-text-color);
   }
-  .material-icon.cancel {
+  gr-icon.cancel {
     color: var(--error-foreground);
   }
 `;
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 71e0a9c..4281f43 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -106,10 +106,10 @@
 export const DELAYED_CANCELLATION = Symbol('Delayed Cancellation');
 
 export class DelayedPromise<T> extends Promise<T> {
-  private readonly resolve: (value: PromiseLike<T> | T) => void;
+  private resolve: (value: PromiseLike<T> | T) => void;
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  private readonly reject: (reason?: any) => void;
+  private reject: (reason?: any) => void;
 
   private timer: number | undefined;
 
@@ -167,11 +167,11 @@
   //    that default behaviour by redefining its @@species property.
   // NOTE: This is required otherwise .then and .catch on a DelayedPromise
   // will try to instantiate a DelayedPromise with 'resolve, reject' arguments.
-  static get [Symbol.species]() {
+  static override get [Symbol.species]() {
     return Promise;
   }
 
-  get [Symbol.toStringTag]() {
+  override get [Symbol.toStringTag]() {
     return 'DelayedPromise';
   }
 }
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 15b1c71..eccb38b 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -182,7 +182,6 @@
 		//   Examples:
 		//   '@polymer/polymer.js' -> '/node_modules/@polymer/polymer.js'
 		//   'page/page.mjs' -> '/node_modules/page.mjs'
-		//   '@polymer/iron-icon' -> '/node_modules/@polymer/iron-icon.js'
 		//   './element/file' -> './element/file.js'
 		moduleImportRegexp = regexp.MustCompile(`(import[^'";]*|export[^'";]*from ?)['"]([^;\s]*?)(\.(m?)js)?['"];`)
 		data = moduleImportRegexp.ReplaceAll(data, []byte("$1'$2.${4}js';"))
diff --git a/resources/com/google/gerrit/server/commit-msg_test.sh b/resources/com/google/gerrit/server/commit-msg_test.sh
index 4f1a3f7..2c256ff 100755
--- a/resources/com/google/gerrit/server/commit-msg_test.sh
+++ b/resources/com/google/gerrit/server/commit-msg_test.sh
@@ -85,11 +85,11 @@
 
   ${hook} input || fail "failed hook execution"
 
-  found=$(grep -c '^Change-Id' input)
+  found=$(grep -c '^Change-Id' input) || :
   if [[ "${found}" != "1" ]]; then
     fail "got ${found} Change-Ids, want 1"
   fi
-  found=$(grep -c '^Change-Id: I123' input)
+  found=$(grep -c '^Change-Id: I123' input) || :
   if [[ "${found}" != "1" ]]; then
     fail "got ${found} Change-Id: I123, want 1"
   fi
@@ -104,6 +104,18 @@
   git config gerrit.createChangeId false
   ${hook} input || fail "failed hook execution"
   git config --unset gerrit.createChangeId
+  found=$(grep -c '^Change-Id' input) || :
+  if [[ "${found}" != "0" ]]; then
+    fail "got ${found} Change-Ids, want 0"
+  fi
+}
+
+function test_suppress_squash {
+  cat << EOF > input
+squash! bla bla
+EOF
+
+  ${hook} input || fail "failed hook execution"
   found=$(grep -c '^Change-Id' input || true)
   if [[ "${found}" != "0" ]]; then
     fail "got ${found} Change-Ids, want 0"
@@ -119,11 +131,11 @@
   git config gerrit.reviewUrl https://myhost/
   ${hook} input || fail "failed hook execution"
   git config --unset gerrit.reviewUrl
-  found=$(grep -c '^Change-Id' input || true)
+  found=$(grep -c '^Change-Id' input) || :
   if [[ "${found}" != "0" ]]; then
     fail "got ${found} Change-Ids, want 0"
   fi
-  found=$(grep -c '^Link: https://myhost/id/I' input || true)
+  found=$(grep -c '^Link: https://myhost/id/I' input) || :
   if [[ "${found}" != "1" ]]; then
     fail "got ${found} Link footers, want 1"
   fi
@@ -138,7 +150,7 @@
 EOF
 
   ${hook} input || fail "failed hook execution"
-  result=$(tail -1 input | grep ^Change-Id)
+  result=$(tail -1 input | grep ^Change-Id) || :
   if [[ -z "${result}" ]] ; then
     echo "after: "
     cat input
@@ -147,6 +159,25 @@
   fi
 }
 
+# Change-Id goes before Signed-off-by trailers.
+function test_before_signed_off_by {
+  cat << EOF > input
+bla bla
+
+Bug: #123
+Signed-off-by: Joe User
+EOF
+
+  ${hook} input || fail "failed hook execution"
+  result=$(tail -2 input | head -1 | grep ^Change-Id) || :
+  if [[ -z "${result}" ]] ; then
+    echo "after: "
+    cat input
+
+    fail "did not find Change-Id before Signed-off-by"
+  fi
+}
+
 function test_dash_at_end {
   if [[ ! -x /bin/dash ]] ; then
     echo "/bin/dash not installed; skipping dash test."
@@ -161,7 +192,7 @@
 
   /bin/dash ${hook} input || fail "failed hook execution"
 
-  result=$(tail -1 input | grep ^Change-Id)
+  result=$(tail -1 input | grep ^Change-Id) || :
   if [[ -z "${result}" ]] ; then
     echo "after: "
     cat input
@@ -184,11 +215,11 @@
 
   /bin/dash ${hook} input || fail "failed hook execution"
 
-  found=$(grep -c '^Change-Id' input)
+  found=$(grep -c '^Change-Id' input) || :
   if [[ "${found}" != "1" ]]; then
     fail "got ${found} Change-Ids, want 1"
   fi
-  found=$(grep -c '^Change-Id: I123' input)
+  found=$(grep -c '^Change-Id: I123' input) || :
   if [[ "${found}" != "1" ]]; then
     fail "got ${found} Change-Id: I123, want 1"
   fi
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index e1d6f22..1ee4423 100755
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -34,6 +34,11 @@
   exit 0
 fi
 
+# Do not create a change id for squash commits.
+if head -n1 "$1" | grep -q '^squash! '; then
+  exit 0
+fi
+
 if git rev-parse --verify HEAD >/dev/null 2>&1; then
   refhash="$(git rev-parse HEAD)"
 else
@@ -43,7 +48,7 @@
 random=$({ git var GIT_COMMITTER_IDENT ; echo "$refhash" ; cat "$1"; } | git hash-object --stdin)
 dest="$1.tmp.${random}"
 
-trap 'rm -f "${dest}"' EXIT
+trap 'rm -f "$dest" "$dest-2"' EXIT
 
 if ! git stripspace --strip-comments < "$1" > "${dest}" ; then
    echo "cannot strip comments from $1"
@@ -57,21 +62,39 @@
 
 reviewurl="$(git config --get gerrit.reviewUrl)"
 if test -n "${reviewurl}" ; then
-  if ! git interpret-trailers --parse < "$1" | grep -q '^Link:.*/id/I[0-9a-f]\{40\}$' ; then
-    if ! git interpret-trailers \
-          --trailer "Link: ${reviewurl%/}/id/I${random}" < "$1" > "${dest}" ; then
-      echo "cannot insert link footer in $1"
-      exit 1
-    fi
-  fi
+  token="Link"
+  value="${reviewurl%/}/id/I$random"
+  pattern=".*/id/I[0-9a-f]\{40\}$"
 else
-  # Avoid the --in-place option which only appeared in Git 2.8
-  # Avoid the --if-exists option which only appeared in Git 2.15
-  if ! git -c trailer.ifexists=doNothing interpret-trailers \
-        --trailer "Change-Id: I${random}" < "$1" > "${dest}" ; then
-    echo "cannot insert change-id line in $1"
-    exit 1
-  fi
+  token="Change-Id"
+  value="I$random"
+  pattern=".*"
+fi
+
+if git interpret-trailers --parse < "$1" | grep -q "^$token: $pattern$" ; then
+  exit 0
+fi
+
+# There must be a Signed-off-by trailer for the code below to work. Insert a
+# sentinel at the end to make sure there is one.
+# Avoid the --in-place option which only appeared in Git 2.8
+if ! git interpret-trailers \
+         --trailer "Signed-off-by: SENTINEL" < "$1" > "$dest-2" ; then
+  echo "cannot insert Signed-off-by sentinel line in $1"
+  exit 1
+fi
+
+# Make sure the trailer appears before any Signed-off-by trailers by inserting
+# it as if it was a Signed-off-by trailer and then use sed to remove the
+# Signed-off-by prefix and the Signed-off-by sentinel line.
+# Avoid the --in-place option which only appeared in Git 2.8
+# Avoid the --where option which only appeared in Git 2.15
+if ! git -c trailer.where=before interpret-trailers \
+         --trailer "Signed-off-by: $token: $value" < "$dest-2" |
+     sed -re "s/^Signed-off-by: ($token: )/\1/" \
+         -e "/^Signed-off-by: SENTINEL/d" > "$dest" ; then
+  echo "cannot insert $token line in $1"
+  exit 1
 fi
 
 if ! mv "${dest}" "$1" ; then